diff --git a/README.md b/README.md index 0dd1e0d..9d0c4d7 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ # Clippy - + A web-based application that allows users to share text and files in real-time through secure, encrypted sessions. @@ -33,10 +33,12 @@ A web-based application that allows users to share text and files in real-time t Tired of sharing text / files across different computers? Try this clippy! -- **Session-based sharing**: Create or join sessions using short, custom-length IDs +- **Session-based sharing**: Create or join sessions using short, custom-length IDs. Server-minted IDs leave out `i`, `o`, `e`, `0` and `1`, so an ID survives being read aloud or copied by eye; name your own and any of `a-z0-9` is fair game. The ID field is a password input so the OS drops out of a Chinese IME on focus — the value stays visible +- **Public Clippys**: Hosts can unlock a session with the padlock beside the QR code to list it on the entry page. The five newest published sessions appear there with name, ID, creation time and last update, and they appear and disappear live over a WebSocket. Everything is private until someone deliberately unlocks it - **Encrypted payloads**: Each session uses a 256-bit AES-GCM key derived client-side from the connection ID (SHA-256 KDF). All block content is encrypted before it leaves the browser, so the backend only ever sees ciphertext. The server issues the ID and could derive the key too — this is encrypted-at-rest and on-the-wire, not strict end-to-end against a malicious server - **Real-time collaboration**: See blocks appear instantly when other users create them -- **File uploads**: Support for small file uploads +- **File uploads**: Support for small file uploads. Drop one or several files anywhere on the session page and they upload straight away — no need to open the composer first +- **Image previews**: Uploaded images are decrypted in the browser and shown as thumbnails - **Curl upload**: Upload text or files from the terminal — host can enable/disable per session ```bash curl -d 'hello' https://your-host/u/SESSION_ID @@ -44,10 +46,10 @@ Tired of sharing text / files across different computers? Try this clippy! ``` - **Raw links**: Generate public short links to share decrypted text or files with anyone (e.g. `https://your-host/r/SESSION_ID/CODE`) - **User management**: - - Custom or random user names + - Custom or random user names, remembered in local storage between visits - Host can transfer host rights to other users - Host can control whether new users can join -- **Session persistence**: Sessions remain active until destroyed by host or after 1 hour of inactivity +- **Session persistence**: Sessions remain active until destroyed by host or after 1 hour of inactivity. When one expires or is destroyed, the tab returns to the dashboard on its own instead of stopping at a warning - **Block system**: Add and delete text or file blocks, similar to Jupyter notebooks --- @@ -129,7 +131,7 @@ docker run -p 8080:80 --env-file backend/.env clippy ### Known Bugs 🐛 -- Destroying the session might not directly take you back to homepage. +- None right now. --- diff --git a/README.zh-TW.md b/README.zh-TW.md index 175feea..f4ed2fe 100644 --- a/README.zh-TW.md +++ b/README.zh-TW.md @@ -4,7 +4,7 @@ # Clippy - + 一個讓使用者能透過安全、加密的的方式,即時分享文字與檔案的網頁應用程式。 @@ -33,10 +33,12 @@ 厭倦了在不同電腦間傳輸文字或檔案嗎?試試這個剪貼工具! -- **基於連線階段 (Session) 的分享**:使用克制化長度的 ID 建立/加入連線。 +- **基於連線階段 (Session) 的分享**:使用克制化長度的 ID 建立/加入連線,可自行指定 ID 或交由伺服器產生。伺服器自動產生的 ID 不會出現 `i`、`o`、`e`、`0`、`1`,避免唸出來或用眼睛抄寫時認錯;自行指定則 `a-z0-9` 皆可使用。ID 欄位為密碼輸入框,讓作業系統在聚焦時自動切離中文輸入法,但輸入內容仍然可見。 +- **公開 Clippy**:主持人可按下 QR Code 旁的鎖頭,把連線列在首頁上。首頁會顯示最新的 5 個公開連線,包含名稱、ID、建立時間與最後更新時間,並透過 WebSocket 即時顯示與隱藏。未經主持人解鎖前一律為私人連線。 - **加密傳輸**:每個連線階段使用一把由連線 ID 透過 SHA-256 衍生的 256 位元 AES-GCM 金鑰,所有文字與檔案在離開瀏覽器前皆會在用戶端加密;伺服器僅儲存密文(伺服器同時持有 ID,因此並非嚴格的端對端加密)。 - **即時協作**:當其他使用者建立區塊時,您能立即看到它們出現。 -- **檔案上傳**:支援小檔案上傳。 +- **檔案上傳**:支援小檔案上傳。也可以直接把一個或多個檔案拖曳到連線頁面的任一處,不必先開啟新增區塊就會自動上傳。 +- **圖片預覽**:上傳的圖片會在瀏覽器中解密並顯示縮圖。 - **Curl 上傳**:透過終端機上傳文字或檔案,主持人可針對每個連線啟用/停用此功能。 ```bash curl -d 'hello' https://your-host/u/SESSION_ID @@ -44,10 +46,10 @@ ``` - **Raw 連結**:產生公開短連結,與任何人分享解密後的文字或檔案(例如 `https://your-host/r/SESSION_ID/CODE`) - **使用者管理**: - - 自訂或隨機使用者名稱 + - 自訂或隨機使用者名稱,名稱會記錄在 local storage 中並於下次沿用 - 主持人 (Host) 可轉移權限給其他使用者 - 主持人可控制是否允許新使用者加入 -- **連線持久性**:連線將保持啟用,直到主持人銷毀或閒置 1 小時後自動結束。 +- **連線持久性**:連線將保持啟用,直到主持人銷毀或閒置 1 小時後自動結束。連線過期或被銷毀時,頁面會直接返回首頁,不再停在警告視窗上。 - **區塊系統**:新增與刪除文字或檔案區塊,操作方式類似 Jupyter notebooks。 --- @@ -129,7 +131,7 @@ docker run -p 8080:80 --env-file backend/.env clippy ### 已知的bug 🐛 -- 銷毀連線階段後,可能無法直接導回首頁。 +- 目前沒有。 --- diff --git a/backend/app.py b/backend/app.py index 666e50d..c02560e 100644 --- a/backend/app.py +++ b/backend/app.py @@ -44,6 +44,17 @@ MAX_FILE_SIZE_GIB = float(os.getenv("MAX_UPLOAD_SIZE_GIB", "1")) SESSION_TIMEOUT_SECONDS = int(os.getenv("SESSION_TIMEOUT_SECONDS", "3600")) CONNECTION_ID_LENGTH = int(os.getenv("CONNECTION_ID_LENGTH", "6")) +# Everything an id may contain. It becomes a directory name under UPLOAD_DIR, +# so this set is a filesystem guard as much as a format rule. +CONNECTION_ID_ALPHABET = string.ascii_lowercase + string.digits +# i/o/e/0/1 are dropped from *generated* ids on purpose: they are the characters +# people confuse on screen (i vs 1, o vs 0) or mishear when an id is read aloud +# across Mandarin and English (e). A caller naming its own id has chosen to own +# that risk, so the restriction stops at the generator. +CONNECTION_ID_EXCLUDED = "ioe01" +CONNECTION_ID_GENERATED_ALPHABET = "".join( + c for c in CONNECTION_ID_ALPHABET if c not in CONNECTION_ID_EXCLUDED +) # Grace period before a disconnected user is removed from the session, allowing # brief network blips and page refreshes to reconnect without churn. DISCONNECT_GRACE_SECONDS = int(os.getenv("DISCONNECT_GRACE_SECONDS", "10")) @@ -138,7 +149,7 @@ async def lifespan(_app: FastAPI): app = FastAPI( title="Clippy API", description="Secure collaborative clipboard with real-time file and text sharing", - version="2.0.1", + version="2.1.0", openapi_url="/api/v2/openapi.json" if ENABLE_DOCS else None, docs_url="/api/v2/docs" if ENABLE_DOCS else None, redoc_url=None, @@ -208,11 +219,16 @@ class SessionInfo(BaseModel): blocks: list[Block] allow_join: bool allow_curl_upload: bool + is_public: bool host_id: str class CreateSessionRequest(BaseModel): user_name: str | None = Field(default=None, max_length=64) + # Optional vanity id. Validated against CONNECTION_ID_ALPHABET before use — + # it ends up as a directory name under UPLOAD_DIR, so nothing outside + # [a-z0-9] may ever reach the filesystem. + connection_id: str | None = Field(default=None, max_length=64) class JoinSessionRequest(BaseModel): @@ -266,6 +282,12 @@ class ToggleCurlRequest(BaseModel): allow_curl_upload: bool +class TogglePublicRequest(BaseModel): + connection_id: str = Field(min_length=1, max_length=64) + user_id: str = Field(min_length=1, max_length=64) + is_public: bool + + class RawLink(BaseModel): code: str connection_id: str @@ -306,6 +328,10 @@ class Session: def __init__(self, connection_id: str, host_token: str, host_id: str, host_name: str): """Initialize a new session with a host user.""" self.connection_id = connection_id + # Lobby label, fixed at creation. Tracking the current host instead made + # a listed room rename itself on a host transfer and fall back to a + # placeholder once everyone had left. + self.name = host_name self.users: dict[str, User] = { host_id: User(id=host_id, name=host_name, is_host=True) } @@ -319,6 +345,11 @@ def __init__(self, connection_id: str, host_token: str, host_id: str, host_name: self.reserved_bytes = 0 self.allow_join = True self.allow_curl_upload = False + # Private until the host says otherwise: publishing a session publishes + # its connection id, which is also the KDF input, so anyone reading the + # lobby can read the content. That has to be a deliberate act. + self.is_public = False + self.created_at = datetime.now() self.last_activity = datetime.now() self.websockets: dict[str, set[WebSocket]] = {} # Pending eviction tasks keyed by public user id so reconnects can cancel them. @@ -349,6 +380,10 @@ def update_activity(self): """Update the last activity timestamp to prevent session timeout.""" self.last_activity = datetime.now() mark_sessions_dirty() + if self.is_public: + # Coalesced into the periodic flush: activity fires on every + # keystroke-sized action, and the lobby only shows it as a timestamp. + mark_lobby_dirty() def is_expired(self) -> bool: """Check if session has exceeded the timeout period.""" @@ -391,9 +426,17 @@ def remove_user(self, user_id: str): self.update_activity() def transfer_host(self, new_host_id: str): - """Transfer host privileges to another user in the session.""" + """Transfer host privileges to another user in the session. + + Publication does not follow the seat. Listing a room is consent given by + one particular person, under their name, and the seat moves on its own + after a 10-second network blip — so a host who slept their laptop in a + public room would otherwise lose the ability to unpublish it to whoever + happened to be next in the dict. A new host who wants it listed can say so. + """ for user in self.users.values(): user.is_host = (user.id == new_host_id) + self.is_public = False self.update_activity() def add_block(self, block: Block, byte_size: int = 0): @@ -441,12 +484,15 @@ def to_dict(self) -> dict: """Serialize persistable session state. Sockets and tasks are runtime-only.""" return { "connection_id": self.connection_id, + "name": self.name, "users": {uid: u.model_dump() for uid, u in self.users.items()}, "tokens": self.tokens, "blocks": {bid: b.model_dump() for bid, b in self.blocks.items()}, "block_bytes": self.block_bytes, "allow_join": self.allow_join, "allow_curl_upload": self.allow_curl_upload, + "is_public": self.is_public, + "created_at": self.created_at.isoformat(), "last_activity": self.last_activity.isoformat(), } @@ -455,6 +501,9 @@ def from_dict(cls, data: dict) -> "Session": instance = cls.__new__(cls) instance.connection_id = data["connection_id"] instance.users = {uid: User(**u) for uid, u in data.get("users", {}).items()} + instance.name = data.get("name") or next( + (u.name for u in instance.users.values() if u.is_host), "Clippy" + ) # Drop tokens pointing at users that no longer exist. instance.tokens = { t: uid for t, uid in data.get("tokens", {}).items() if uid in instance.users @@ -465,7 +514,13 @@ def from_dict(cls, data: dict) -> "Session": instance.reserved_bytes = 0 instance.allow_join = data.get("allow_join", True) instance.allow_curl_upload = data.get("allow_curl_upload", False) + instance.is_public = data.get("is_public", False) instance.last_activity = datetime.fromisoformat(data["last_activity"]) + # Snapshots written before created_at existed fall back to the last + # activity rather than "now", which would reshuffle the lobby on restart. + instance.created_at = datetime.fromisoformat( + data.get("created_at") or data["last_activity"] + ) instance.websockets = {} instance.pending_disconnects = {} instance.session_dir = UPLOAD_DIR / instance.connection_id @@ -514,6 +569,53 @@ def load_sessions_sync() -> None: logger.warning("Skipping malformed session %s: %s", sid, e) +# Public lobby: sessions their host has chosen to publish, streamed to anyone +# sitting on the entry page. Sockets here are unauthenticated by design — the +# whole point is that a published session is discoverable without an id. +MAX_PUBLIC_SESSIONS = 5 +lobby_sockets: set[WebSocket] = set() +_lobby_dirty = False + + +def mark_lobby_dirty() -> None: + global _lobby_dirty + _lobby_dirty = True + + +def public_session_entries() -> list[dict]: + """The newest published sessions, newest first.""" + entries = [ + { + "connection_id": s.connection_id, + "name": s.name, + # Stamped with the server's offset. A bare local timestamp is read + # by the browser as *its own* local time, so a UTC container served + # a UTC+8 reader a room created "8 hr ago" the moment it appeared. + "created_at": s.created_at.astimezone().isoformat(), + "last_activity": s.last_activity.astimezone().isoformat(), + } + for s in sessions.values() + if s.is_public + ] + entries.sort(key=lambda e: e["created_at"], reverse=True) + return entries[:MAX_PUBLIC_SESSIONS] + + +async def broadcast_public_sessions() -> None: + """Push the current lobby to every listener. Call directly whenever a + session appears or disappears; timestamp-only churn rides the flush loop.""" + global _lobby_dirty + _lobby_dirty = False + if not lobby_sockets: + return + message = {"type": "public_sessions", "sessions": public_session_entries()} + for ws in list(lobby_sockets): + try: + await ws.send_json(message) + except Exception: # noqa: BLE001 — one dead listener must not stop the rest. + lobby_sockets.discard(ws) + + async def persistence_loop() -> None: """Background task that flushes dirty flags at a fixed interval.""" global _sessions_dirty, _raw_links_dirty @@ -522,6 +624,8 @@ async def persistence_loop() -> None: await asyncio.sleep(PERSIST_INTERVAL_SECONDS) except asyncio.CancelledError: return + if _lobby_dirty: + await broadcast_public_sessions() if _sessions_dirty: _sessions_dirty = False try: @@ -679,15 +783,33 @@ def new_member() -> tuple[str, str]: return secrets.token_urlsafe(32), uuid.uuid4().hex[:12] +# Paths under /ws that are not sessions. A session named after one would be +# reachable over HTTP but its live socket would land on the literal route +# registered ahead of /ws/{connection_id}, so it would never receive an event. +RESERVED_CONNECTION_IDS = frozenset({"lobby"}) + + +def validate_connection_id(candidate: str) -> str | None: + """Return None when a user-supplied id is usable, else why it is not.""" + if len(candidate) != CONNECTION_ID_LENGTH: + return f"Connection ID must be exactly {CONNECTION_ID_LENGTH} characters" + rejected = sorted({c for c in candidate if c not in CONNECTION_ID_ALPHABET}) + if rejected: + return f"Connection ID cannot contain: {' '.join(rejected)}" + if candidate in RESERVED_CONNECTION_IDS: + return "Connection ID is reserved" + return None + + def generate_connection_id() -> str | None: """ - Generate a unique connection ID using lowercase letters and digits. + Generate a unique connection ID from the confusion-free alphabet. Returns None only when the keyspace is genuinely exhausted; otherwise the capped attempt loop will find a free ID with overwhelming probability long before the cap is hit. """ - chars = string.ascii_lowercase + string.digits + chars = CONNECTION_ID_GENERATED_ALPHABET max_possible = len(chars) ** CONNECTION_ID_LENGTH if len(sessions) >= max_possible: @@ -772,6 +894,9 @@ async def _teardown_session(connection_id: str, reason: str): mark_sessions_dirty() + if session.is_public: + await broadcast_public_sessions() + async def cleanup_expired_sessions(): """Periodically reap sessions and stale raw links.""" @@ -833,14 +958,17 @@ async def get_connection_id_length(): """ Get the configured connection ID length. - Returns the length of connection IDs generated by the server. - Used by the client to validate session ID input. + Returns the length of connection IDs, plus every character one may contain, + so the client can filter input against the server's rule instead of keeping + its own copy of it. This is the *accepted* set: generated IDs are drawn from + a narrower one, but a caller may name an ID using any of these. """ return api_response( HTTPStatus.OK, "Connection ID length retrieved", { "connection_id_length": CONNECTION_ID_LENGTH, + "connection_id_alphabet": CONNECTION_ID_ALPHABET, } ) @@ -854,12 +982,29 @@ async def create_session(request: CreateSessionRequest): """ Create a new collaborative session. - Generates a unique 6-character session ID and creates the first user as the host. - If no user name is provided, a random name will be generated. + Generates a unique session ID and creates the first user as the host. + A caller may request its own ID; it still has to match the server's length + and alphabet, and must not already be taken. If no user name is provided, + a random name will be generated. Returns session ID, user ID, user name, and host status. """ - connection_id = generate_connection_id() + requested_id = (request.connection_id or "").strip().lower() + + if requested_id: + # A chosen id is guessable in a way a generated one is not, and the id + # is also the KDF input — that trade-off is the caller's to make. What + # is not negotiable is the character set: it guards the session + # directory path. Confusable characters are allowed here; only the + # generator avoids them. + invalid = validate_connection_id(requested_id) + if invalid is not None: + return api_response(HTTPStatus.BAD_REQUEST, invalid) + if requested_id in sessions: + return api_response(HTTPStatus.CONFLICT, "Connection ID already in use") + connection_id = requested_id + else: + connection_id = generate_connection_id() if connection_id is None: return api_response( @@ -868,7 +1013,9 @@ async def create_session(request: CreateSessionRequest): ) token, user_id = new_member() - user_name = request.user_name or generate_random_name() + # .strip(): a name of pure spaces is truthy, and it rendered as a blank + # row in the public lobby and a blank entry in the member list. + user_name = (request.user_name or "").strip() or generate_random_name() session = Session(connection_id, token, user_id, user_name) sessions[connection_id] = session @@ -921,7 +1068,9 @@ async def join_session(request: JoinSessionRequest): ) token, user_id = new_member() - user_name = request.user_name or generate_random_name() + # .strip(): a name of pure spaces is truthy, and it rendered as a blank + # row in the public lobby and a blank entry in the member list. + user_name = (request.user_name or "").strip() or generate_random_name() user = session.add_user(token, user_id, user_name) @@ -971,6 +1120,7 @@ async def get_session(connection_id: str, request: Request): blocks=list(session.blocks.values()), allow_join=session.allow_join, allow_curl_upload=session.allow_curl_upload, + is_public=session.is_public, host_id=next((u.id for u in session.users.values() if u.is_host), ""), ) @@ -1056,6 +1206,7 @@ async def transfer_host(request: TransferHostRequest): "New host user not found" ) + was_public = session.is_public session.transfer_host(request.new_host_id) # Broadcast host transfer @@ -1064,6 +1215,10 @@ async def transfer_host(request: TransferHostRequest): "new_host_id": request.new_host_id }) + if was_public: + await session.broadcast({"type": "public_changed", "is_public": False}) + await broadcast_public_sessions() + return api_response( HTTPStatus.OK, "Host transferred", @@ -1142,6 +1297,61 @@ async def toggle_curl(request: ToggleCurlRequest): return api_response(HTTPStatus.OK, "Curl upload permission updated", {"success": True}) +@router.post( + "/session/toggle_public", + summary="Toggle Public Listing", + description="Publish or unpublish the session on the entry page (host only)" +) +async def toggle_public(request: TogglePublicRequest): + """ + Publish the session in the public lobby, or take it back down. + + Host only, and off by default: the lobby hands out the connection id, and + the connection id is what derives the content key. + """ + connection_id = request.connection_id.lower() + + if connection_id not in sessions: + return api_response(HTTPStatus.NOT_FOUND, "Session not found") + + session = sessions[connection_id] + + if not session.is_host_token(request.user_id): + return api_response(HTTPStatus.FORBIDDEN, "Only host can change visibility") + + if session.is_public == request.is_public: + # Nothing changed, so nothing to fan out. Without this, repeating the + # call is a free lobby-broadcast amplifier: the frames queue up in + # memory against any listener that has stopped reading. + return api_response(HTTPStatus.OK, "Visibility unchanged", {"success": True}) + + session.is_public = request.is_public + session.update_activity() + + await session.broadcast({ + "type": "public_changed", + "is_public": session.is_public, + }) + # Appearing and disappearing is the one thing the lobby must show at once. + await broadcast_public_sessions() + + return api_response(HTTPStatus.OK, "Visibility updated", {"success": True}) + + +@router.get( + "/sessions/public", + summary="List Public Sessions", + description="The newest published sessions, for the entry page" +) +async def list_public_sessions(): + """Snapshot of the lobby. Live updates arrive over ``/ws/lobby``.""" + return api_response( + HTTPStatus.OK, + "Public sessions retrieved", + {"sessions": public_session_entries()}, + ) + + @router.post( "/block/create", summary="Create Text Block", @@ -1902,17 +2112,58 @@ async def _remove_user_after_grace(connection_id: str, user_id: str): return was_host = user.is_host + was_public = session.is_public session.remove_user(user_id) await session.broadcast({"type": "user_left", "user_id": user_id}) - if was_host and session.users: - new_host_id = next(iter(session.users)) - session.transfer_host(new_host_id) - await session.broadcast({ - "type": "host_transferred", - "new_host_id": new_host_id, + if was_host: + if session.users: + new_host_id = next(iter(session.users)) + session.transfer_host(new_host_id) # also unpublishes — see the method + await session.broadcast({ + "type": "host_transferred", + "new_host_id": new_host_id, + }) + else: + # Nobody is left to hold the seat, so nobody could ever unpublish or + # destroy it again. An unlisted room just waits out SESSION_TIMEOUT. + session.is_public = False + + if was_public: + await session.broadcast({"type": "public_changed", "is_public": False}) + await broadcast_public_sessions() + + +@app.websocket("/ws/lobby") +async def lobby_websocket(websocket: WebSocket): + """Live feed of published sessions for the entry page. + + Unauthenticated on purpose — it only ever carries what a host explicitly + published. Registered before ``/ws/{connection_id}`` so the literal path + wins over the parameterised one. + """ + await websocket.accept() + lobby_sockets.add(websocket) + try: + await websocket.send_json({ + "type": "public_sessions", + "sessions": public_session_entries(), }) + while True: + data = await websocket.receive_text() + try: + message = json.loads(data) + except json.JSONDecodeError: + continue + if message.get("type") == "ping": + await websocket.send_json({"type": "pong"}) + except WebSocketDisconnect: + pass + except Exception as e: # noqa: BLE001 — log and clean up regardless of cause. + logger.warning("Lobby WebSocket error: %s", e) + finally: + lobby_sockets.discard(websocket) @app.websocket("/ws/{connection_id}") @@ -1957,8 +2208,12 @@ async def websocket_endpoint(websocket: WebSocket, connection_id: str): except json.JSONDecodeError: continue if message.get("type") == "ping": + # Deliberately does not touch last_activity: the keepalive fires + # every 30s whether anyone is there or not, so refreshing on it + # made a forgotten open tab keep a dead session alive forever. + # SESSION_TIMEOUT_SECONDS is meant to measure idleness, not + # whether a browser is still pointed at the page. await websocket.send_json({"type": "pong"}) - session.update_activity() except WebSocketDisconnect: pass except Exception as e: # noqa: BLE001 — log and clean up regardless of cause. diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 717d894..c5022d8 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "clippy-backend" -version = "2.0.1" +version = "2.1.0" description = "Clippy - Collaborative clipboard backend" requires-python = ">=3.12" dependencies = [ diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index f4970ae..f837360 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -42,6 +42,10 @@ def client(): # TestClient(app) handles lifespan startup/shutdown via context manager. with TestClient(app_module.app) as c: yield c - # Clean module-level state between tests so each one starts fresh. + # Clean module-level state between tests so each one starts fresh. The + # on-disk snapshot has to go too: shutdown flushes it and the next client's + # startup restores it, so clearing only the dicts leaks state forward. app_module.sessions.clear() app_module.raw_links.clear() + app_module.SESSIONS_FILE.unlink(missing_ok=True) + app_module.RAW_LINKS_FILE.unlink(missing_ok=True) diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index f004b29..5c1a879 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -33,6 +33,66 @@ def test_create_session_returns_unique_id(client): assert a["user_id"] != a["public_id"] +def test_generated_ids_avoid_confusable_characters(client): + """i/o/e/0/1 must never be *generated* — they are the ones users misread.""" + for _ in range(30): + cid = _create_session(client)["connection_id"] + assert not set(cid) & set("ioe01"), cid + + +def test_custom_connection_id_is_honoured_and_validated(client): + import app as app_module + + good = "z" * app_module.CONNECTION_ID_LENGTH + r = client.post("/api/v2/session/create", json={"connection_id": good.upper()}) + assert r.status_code == 200, r.text + assert r.json()["data"]["connection_id"] == good + + # Taken. + r = client.post("/api/v2/session/create", json={"connection_id": good}) + assert r.status_code == 409 + + # A confusable character is the caller's business when they name it. + r = client.post("/api/v2/session/create", json={"connection_id": "e" + good[1:]}) + assert r.status_code == 200 + assert r.json()["data"]["connection_id"] == "e" + good[1:] + + # Wrong length. + r = client.post("/api/v2/session/create", json={"connection_id": good[:-1]}) + assert r.status_code == 400 + + # Path traversal via the id would land in the uploads dir. + r = client.post("/api/v2/session/create", json={"connection_id": "../abc"}) + assert r.status_code == 400 + + # "lobby" is a live WebSocket route, so a session named after it would never + # receive an event. + app_module.CONNECTION_ID_LENGTH = 5 + try: + r = client.post("/api/v2/session/create", json={"connection_id": "lobby"}) + assert r.status_code == 400 + finally: + app_module.CONNECTION_ID_LENGTH = len(good) + + +def test_blank_names_do_not_reach_the_lobby(client): + r = client.post("/api/v2/session/create", json={"user_name": " "}) + assert r.status_code == 200 + member = r.json()["data"] + assert member["user_name"].strip() + _publish(client, member) + assert _public(client)[0]["name"].strip() + + +def test_toggling_visibility_to_its_current_value_is_a_no_op(client): + host = _create_session(client) + _publish(client, host) + listed = _public(client) + # Repeating it must not fan out again — it is an unauthenticated broadcast. + _publish(client, host) + assert _public(client) == listed + + def test_join_then_get_session_requires_membership(client): host = _create_session(client) cid = host["connection_id"] @@ -87,6 +147,176 @@ def test_guest_cannot_seize_host(client): }).status_code == 200 +def _public(client): + r = client.get("/api/v2/sessions/public") + assert r.status_code == 200, r.text + return r.json()["data"]["sessions"] + + +def test_sessions_are_private_until_the_host_publishes_them(client): + host = _create_session(client) + cid = host["connection_id"] + guest = _join(client, cid) + + assert _public(client) == [] + + # A guest must not be able to publish someone else's session. + r = client.post("/api/v2/session/toggle_public", json={ + "connection_id": cid, "user_id": guest["user_id"], "is_public": True, + }) + assert r.status_code == 403 + assert _public(client) == [] + + r = client.post("/api/v2/session/toggle_public", json={ + "connection_id": cid, "user_id": host["user_id"], "is_public": True, + }) + assert r.status_code == 200 + + listed = _public(client) + assert [e["connection_id"] for e in listed] == [cid] + assert listed[0]["name"] == "Alice" + assert listed[0]["created_at"] and listed[0]["last_activity"] + assert client.get(f"/api/v2/session/{cid}", headers=_auth(host)).json()["data"]["is_public"] is True + + # A guest cannot take it down either. + r = client.post("/api/v2/session/toggle_public", json={ + "connection_id": cid, "user_id": guest["user_id"], "is_public": False, + }) + assert r.status_code == 403 + + r = client.post("/api/v2/session/toggle_public", json={ + "connection_id": cid, "user_id": host["user_id"], "is_public": False, + }) + assert r.status_code == 200 + assert _public(client) == [] + + +def _publish(client, member, expect=200): + r = client.post("/api/v2/session/toggle_public", json={ + "connection_id": member["connection_id"], + "user_id": member["user_id"], + "is_public": True, + }) + assert r.status_code == expect, r.text + + +def test_publishing_is_revoked_when_the_host_seat_moves(client): + """The bug this guards: a 10s network blip handed the host seat to whoever + joined from the lobby, and the original host could no longer unpublish or + destroy the room that still carried her name.""" + import asyncio + + import app as app_module + + host = _create_session(client) + cid = host["connection_id"] + guest = _join(client, cid, name="Mallory") + _publish(client, host) + assert [e["connection_id"] for e in _public(client)] == [cid] + + # Exactly what a dropped socket triggers, without waiting out the grace period. + app_module.DISCONNECT_GRACE_SECONDS = 0 + asyncio.run(app_module._remove_user_after_grace(cid, host["public_id"])) + + session = app_module.sessions[cid] + assert session.users[guest["public_id"]].is_host is True + assert session.is_public is False + assert _public(client) == [] + + +def test_publishing_is_revoked_when_the_last_member_leaves(client): + """An abandoned public room had no host token left in existence, so nobody + could ever unpublish or destroy it — it just sat in the lobby, readable.""" + import asyncio + + import app as app_module + + host = _create_session(client) + cid = host["connection_id"] + _publish(client, host) + + app_module.DISCONNECT_GRACE_SECONDS = 0 + asyncio.run(app_module._remove_user_after_grace(cid, host["public_id"])) + + assert app_module.sessions[cid].users == {} + assert app_module.sessions[cid].is_public is False + assert _public(client) == [] + + +def test_explicit_host_transfer_also_unpublishes(client): + host = _create_session(client) + cid = host["connection_id"] + guest = _join(client, cid) + _publish(client, host) + + r = client.post("/api/v2/session/transfer_host", json={ + "connection_id": cid, + "current_host_id": host["user_id"], + "new_host_id": guest["public_id"], + }) + assert r.status_code == 200 + assert _public(client) == [] + # The new host can put it back up under their own say-so. + _publish(client, guest) + assert [e["connection_id"] for e in _public(client)] == [cid] + + +def test_lobby_socket_pushes_appearances_and_disappearances(client): + host = _create_session(client) + cid = host["connection_id"] + + with client.websocket_connect("/ws/lobby") as ws: + assert ws.receive_json() == {"type": "public_sessions", "sessions": []} + + client.post("/api/v2/session/toggle_public", json={ + "connection_id": cid, "user_id": host["user_id"], "is_public": True, + }) + appeared = ws.receive_json() + assert [e["connection_id"] for e in appeared["sessions"]] == [cid] + + client.post("/api/v2/session/toggle_public", json={ + "connection_id": cid, "user_id": host["user_id"], "is_public": False, + }) + assert ws.receive_json()["sessions"] == [] + + +def test_public_listing_is_capped_and_newest_first(client): + import app as app_module + + created = [] + for _ in range(app_module.MAX_PUBLIC_SESSIONS + 2): + host = _create_session(client) + r = client.post("/api/v2/session/toggle_public", json={ + "connection_id": host["connection_id"], + "user_id": host["user_id"], + "is_public": True, + }) + assert r.status_code == 200 + created.append(host["connection_id"]) + + listed = [e["connection_id"] for e in _public(client)] + assert len(listed) == app_module.MAX_PUBLIC_SESSIONS + assert set(listed) <= set(created) + # Newest first, so the two oldest fell off the end. + assert created[-1] in listed + assert created[0] not in listed + + +def test_destroying_a_public_session_removes_it_from_the_lobby(client): + host = _create_session(client) + cid = host["connection_id"] + client.post("/api/v2/session/toggle_public", json={ + "connection_id": cid, "user_id": host["user_id"], "is_public": True, + }) + assert _public(client) + + r = client.post("/api/v2/session/destroy", json={ + "connection_id": cid, "user_id": host["user_id"], + }) + assert r.status_code == 200 + assert _public(client) == [] + + def test_text_block_length_limit_enforced(client): host = _create_session(client) payload = { diff --git a/backend/uv.lock b/backend/uv.lock index 9603bad..74921a4 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -150,7 +150,7 @@ wheels = [ [[package]] name = "clippy-backend" -version = "2.0.1" +version = "2.1.0" source = { virtual = "." } dependencies = [ { name = "aiofiles" }, diff --git a/docker/nginx/default.conf b/docker/nginx/default.conf index 300a8dd..16f328b 100644 --- a/docker/nginx/default.conf +++ b/docker/nginx/default.conf @@ -3,6 +3,11 @@ limit_req_zone $binary_remote_addr zone=mylimit:10m rate=10r/s; # most needs a brute-force brake — it previously had none. limit_req_zone $binary_remote_addr zone=rawlimit:10m rate=2r/s; limit_conn_zone $binary_remote_addr zone=wsconn:10m; +# The lobby feed is unauthenticated and every visitor idling on the entry page +# holds one, so it gets its own budget. Sharing wsconn meant twenty people +# behind one NAT — or one attacker opening twenty anonymous sockets — could +# stop anyone else on that address from opening a session socket at all. +limit_conn_zone $binary_remote_addr zone=wslobby:10m; # When another proxy (TLS terminator, ALB) sits in front, $binary_remote_addr is # that proxy's address and every limiter above collapses into one global bucket. @@ -30,7 +35,9 @@ server { add_header X-Content-Type-Options nosniff always; add_header X-Frame-Options DENY always; add_header Referrer-Policy no-referrer always; - add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' ws: wss:; frame-ancestors 'none'; base-uri 'none'; form-action 'none'" always; + # blob: is allowed for images only: thumbnails are decrypted in the tab and + # painted from an object URL. Nothing else may load from blob:. + add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; connect-src 'self' ws: wss:; frame-ancestors 'none'; base-uri 'none'; form-action 'none'" always; location / { limit_req zone=mylimit burst=10 nodelay; @@ -84,6 +91,20 @@ server { # allowlist on disallowed ones, making ALLOWED_ORIGINS do nothing. } + # Exact match, so it is chosen over the /ws prefix below. + location = /ws/lobby { + limit_conn wslobby 5; + proxy_pass http://clippy-python:8123; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 86400; + } + location /ws { limit_conn wsconn 20; proxy_pass http://clippy-python:8123; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 72d217d..67d01dc 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,24 +1,24 @@ { "name": "frontend", - "version": "2.0.1", + "version": "2.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "frontend", - "version": "2.0.1", + "version": "2.1.0", "dependencies": { "highlight.js": "^11.11.1", "qrcode": "^1.5.4", - "react": "^19.2.3", - "react-dom": "^19.2.3" + "react": "^19.2.8", + "react-dom": "^19.2.8" }, "devDependencies": { - "@vitejs/plugin-react": "^5.1.2", - "eslint": "^9.16.0", - "eslint-plugin-react": "^7.37.2", - "eslint-plugin-react-hooks": "^5.1.0", - "vite": "^7.2.4" + "@vitejs/plugin-react": "^6.0.4", + "eslint": "^9.39.5", + "eslint-plugin-react": "^7.37.5", + "eslint-plugin-react-hooks": "^7.1.1", + "vite": "^8.1.5" } }, "node_modules/@babel/code-frame": { @@ -153,16 +153,6 @@ "@babel/core": "^7.0.0" } }, - "node_modules/@babel/helper-plugin-utils": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", - "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@babel/helper-string-parser": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", @@ -223,38 +213,6 @@ "node": ">=6.0.0" } }, - "node_modules/@babel/plugin-transform-react-jsx-self": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", - "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-react-jsx-source": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", - "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, "node_modules/@babel/template": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", @@ -272,477 +230,69 @@ }, "node_modules/@babel/traverse": { "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", - "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.29.7", - "@babel/generator": "^7.29.7", - "@babel/helper-globals": "^7.29.7", - "@babel/parser": "^7.29.7", - "@babel/template": "^7.29.7", - "@babel/types": "^7.29.7", - "debug": "^4.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/types": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", - "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-string-parser": "^7.29.7", - "@babel/helper-validator-identifier": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", - "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", - "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", - "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", - "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", - "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", - "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", - "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", - "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", - "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", - "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", - "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", - "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", - "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", - "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", - "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", - "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", - "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", - "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", - "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", - "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", - "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", - "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", - "cpu": [ - "arm64" - ], + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, "engines": { - "node": ">=18" + "node": ">=6.9.0" } }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", - "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", - "cpu": [ - "x64" - ], + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, "engines": { - "node": ">=18" + "node": ">=6.9.0" } }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", - "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", - "cpu": [ - "arm64" - ], + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", "dev": true, "license": "MIT", "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" } }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", - "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", - "cpu": [ - "ia32" - ], + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", "dev": true, "license": "MIT", "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" + "dependencies": { + "tslib": "^2.4.0" } }, - "node_modules/@esbuild/win32-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", - "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", - "cpu": [ - "x64" - ], + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", "dev": true, "license": "MIT", "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" + "dependencies": { + "tslib": "^2.4.0" } }, "node_modules/@eslint-community/eslint-utils": { @@ -1005,31 +555,39 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-rc.3", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", - "integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==", + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", - "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", - "cpu": [ - "arm" - ], + "node_modules/@oxc-project/types": { + "version": "0.139.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", + "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "android" - ] + "funding": { + "url": "https://github.com/sponsors/Boshen" + } }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", - "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", + "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==", "cpu": [ "arm64" ], @@ -1038,12 +596,15 @@ "optional": true, "os": [ "android" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", - "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz", + "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==", "cpu": [ "arm64" ], @@ -1052,12 +613,15 @@ "optional": true, "os": [ "darwin" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", - "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz", + "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==", "cpu": [ "x64" ], @@ -1066,26 +630,15 @@ "optional": true, "os": [ "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", - "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", - "cpu": [ - "arm64" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", - "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz", + "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", "cpu": [ "x64" ], @@ -1094,84 +647,36 @@ "optional": true, "os": [ "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", - "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", - "cpu": [ - "arm" - ], - "dev": true, - "libc": [ - "glibc" ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", - "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz", + "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", "cpu": [ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", - "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "glibc" ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", - "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz", + "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", "cpu": [ "arm64" ], "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", - "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", - "cpu": [ - "loong64" - ], - "dev": true, "libc": [ "glibc" ], @@ -1179,14 +684,17 @@ "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", - "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz", + "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", "cpu": [ - "loong64" + "arm64" ], "dev": true, "libc": [ @@ -1196,50 +704,19 @@ "optional": true, "os": [ "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", - "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", - "cpu": [ - "ppc64" - ], - "dev": true, - "libc": [ - "glibc" ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", - "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz", + "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", "cpu": [ "ppc64" ], "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", - "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", - "cpu": [ - "riscv64" - ], - "dev": true, "libc": [ "glibc" ], @@ -1247,29 +724,15 @@ "optional": true, "os": [ "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", - "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", - "cpu": [ - "riscv64" - ], - "dev": true, - "libc": [ - "musl" ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", - "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz", + "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", "cpu": [ "s390x" ], @@ -1281,12 +744,15 @@ "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", - "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", + "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", "cpu": [ "x64" ], @@ -1298,12 +764,15 @@ "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", - "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz", + "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", "cpu": [ "x64" ], @@ -1315,26 +784,15 @@ "optional": true, "os": [ "linux" - ] - }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", - "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", - "cpu": [ - "x64" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", - "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz", + "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", "cpu": [ "arm64" ], @@ -1343,107 +801,80 @@ "optional": true, "os": [ "openharmony" - ] - }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", - "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", - "cpu": [ - "arm64" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", - "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz", + "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==", "cpu": [ - "ia32" + "wasm32" ], "dev": true, "license": "MIT", "optional": true, - "os": [ - "win32" - ] + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", - "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", + "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", "cpu": [ - "x64" + "arm64" ], "dev": true, "license": "MIT", "optional": true, "os": [ "win32" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", - "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz", + "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", "cpu": [ "x64" ], "dev": true, "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@types/babel__core": { - "version": "7.20.5", - "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", - "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.20.7", - "@babel/types": "^7.20.7", - "@types/babel__generator": "*", - "@types/babel__template": "*", - "@types/babel__traverse": "*" - } - }, - "node_modules/@types/babel__generator": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", - "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.0.0" - } - }, - "node_modules/@types/babel__template": { - "version": "7.4.4", - "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", - "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.1.0", - "@babel/types": "^7.0.0" + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@types/babel__traverse": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", - "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", "dev": true, "license": "MIT", + "optional": true, "dependencies": { - "@babel/types": "^7.28.2" + "tslib": "^2.4.0" } }, "node_modules/@types/estree": { @@ -1461,30 +892,35 @@ "license": "MIT" }, "node_modules/@vitejs/plugin-react": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz", - "integrity": "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==", + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.4.tgz", + "integrity": "sha512-XcCQz0TBpBgljhj0gMuuDj49i6Ytqh5q1osT/Gp5uAVJUCTWxyskk/l1jwYYiu2xcNHHipdMz40EGfM1VdamVg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/core": "^7.29.0", - "@babel/plugin-transform-react-jsx-self": "^7.27.1", - "@babel/plugin-transform-react-jsx-source": "^7.27.1", - "@rolldown/pluginutils": "1.0.0-rc.3", - "@types/babel__core": "^7.20.5", - "react-refresh": "^0.18.0" + "@rolldown/pluginutils": "^1.0.1" }, "engines": { "node": "^20.19.0 || >=22.12.0" }, "peerDependencies": { - "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } } }, "node_modules/acorn": { - "version": "8.17.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", - "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", "dev": true, "license": "MIT", "bin": { @@ -1724,9 +1160,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.10.43", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.43.tgz", - "integrity": "sha512-AjYpR78kDWAY3Efj+cDTFH9t9SCoL7OoTp1BOb0mQV7S+6CiLwnWM3FyxhJtdPufDFKzmCSFoUncKjWgJEZTCQ==", + "version": "2.11.1", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.1.tgz", + "integrity": "sha512-HYXq73DDpCtNzOmrFsm9eSwCvWCql0RzqjpDzXN9EadiLJ4DNat0nsZ/Bzmy+Ud12mb4/zKDY0cQ805ZzN+i0A==", "dev": true, "license": "Apache-2.0", "bin": { @@ -1748,9 +1184,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.6", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.6.tgz", - "integrity": "sha512-FQBYNK15VMslhLHpA7+n+n1GOlF1kId2xcCg7/j95f24AOF6VDYMNH4mFxF7KuaTdv627faazpOAjFzMrfJOUw==", + "version": "4.28.7", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.7.tgz", + "integrity": "sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==", "dev": true, "funding": [ { @@ -1768,9 +1204,9 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.10.42", - "caniuse-lite": "^1.0.30001803", - "electron-to-chromium": "^1.5.389", + "baseline-browser-mapping": "^2.10.44", + "caniuse-lite": "^1.0.30001806", + "electron-to-chromium": "^1.5.393", "node-releases": "^2.0.51", "update-browserslist-db": "^1.2.3" }, @@ -2070,6 +1506,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/dijkstrajs": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", @@ -2105,9 +1551,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.393", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.393.tgz", - "integrity": "sha512-kiDJdIUawuEIcp9XoICKp1iTYDEbgguIPq526N1Q7jIQDeQ3CqoMx71025PI/7E48Ddtw2HuWsVjY7afEgNxmg==", + "version": "1.5.395", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.395.tgz", + "integrity": "sha512-7zt9Aw+SrmxLWLN0zhaTWZQiCdryLVrYTq5R7iZakLvi2UQPYMMsROYV/2qVCzMeCiSXHwKOU+sZ4zOVVlrtKA==", "dev": true, "license": "ISC" }, @@ -2316,48 +1762,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/esbuild": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", - "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.28.1", - "@esbuild/android-arm": "0.28.1", - "@esbuild/android-arm64": "0.28.1", - "@esbuild/android-x64": "0.28.1", - "@esbuild/darwin-arm64": "0.28.1", - "@esbuild/darwin-x64": "0.28.1", - "@esbuild/freebsd-arm64": "0.28.1", - "@esbuild/freebsd-x64": "0.28.1", - "@esbuild/linux-arm": "0.28.1", - "@esbuild/linux-arm64": "0.28.1", - "@esbuild/linux-ia32": "0.28.1", - "@esbuild/linux-loong64": "0.28.1", - "@esbuild/linux-mips64el": "0.28.1", - "@esbuild/linux-ppc64": "0.28.1", - "@esbuild/linux-riscv64": "0.28.1", - "@esbuild/linux-s390x": "0.28.1", - "@esbuild/linux-x64": "0.28.1", - "@esbuild/netbsd-arm64": "0.28.1", - "@esbuild/netbsd-x64": "0.28.1", - "@esbuild/openbsd-arm64": "0.28.1", - "@esbuild/openbsd-x64": "0.28.1", - "@esbuild/openharmony-arm64": "0.28.1", - "@esbuild/sunos-x64": "0.28.1", - "@esbuild/win32-arm64": "0.28.1", - "@esbuild/win32-ia32": "0.28.1", - "@esbuild/win32-x64": "0.28.1" - } - }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -2475,16 +1879,23 @@ } }, "node_modules/eslint-plugin-react-hooks": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.2.0.tgz", - "integrity": "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz", + "integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==", "dev": true, "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, "engines": { - "node": ">=10" + "node": ">=18" }, "peerDependencies": { - "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" } }, "node_modules/eslint-scope": { @@ -2982,6 +2393,23 @@ "node": ">= 0.4" } }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, "node_modules/highlight.js": { "version": "11.11.1", "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz", @@ -3490,9 +2918,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", - "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", "dev": true, "funding": [ { @@ -3599,6 +3027,279 @@ "node": ">= 0.8.0" } }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -3994,9 +3695,9 @@ } }, "node_modules/postcss": { - "version": "8.5.20", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.20.tgz", - "integrity": "sha512-lW616l85ucIQL+FocMmL7pQFPqBmwejrCMg+iPxyImlrANNJG9NHq/RkyCZopDhd8C3LA03PHRJDjkbGu8vvug==", + "version": "8.5.22", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.22.tgz", + "integrity": "sha512-KBDEIpLrvpv16pp3K0Fw+UCoZfopFjjgeB+0tA/aaThfEE74kKDLrgg603YvOWJyg3+WYtyq3xYsQWsIyZlPqQ==", "dev": true, "funding": [ { @@ -4072,24 +3773,24 @@ } }, "node_modules/react": { - "version": "19.2.7", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", - "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", "license": "MIT", "engines": { "node": ">=0.10.0" } }, "node_modules/react-dom": { - "version": "19.2.7", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", - "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", + "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", "license": "MIT", "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { - "react": "^19.2.7" + "react": "^19.2.8" } }, "node_modules/react-is": { @@ -4099,16 +3800,6 @@ "dev": true, "license": "MIT" }, - "node_modules/react-refresh": { - "version": "0.18.0", - "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", - "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -4202,49 +3893,38 @@ "node": ">=4" } }, - "node_modules/rollup": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", - "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "node_modules/rolldown": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", + "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", "dev": true, "license": "MIT", "dependencies": { - "@types/estree": "1.0.9" + "@oxc-project/types": "=0.139.0", + "@rolldown/pluginutils": "^1.0.0" }, "bin": { - "rollup": "dist/bin/rollup" + "rolldown": "bin/cli.mjs" }, "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" + "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.62.2", - "@rollup/rollup-android-arm64": "4.62.2", - "@rollup/rollup-darwin-arm64": "4.62.2", - "@rollup/rollup-darwin-x64": "4.62.2", - "@rollup/rollup-freebsd-arm64": "4.62.2", - "@rollup/rollup-freebsd-x64": "4.62.2", - "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", - "@rollup/rollup-linux-arm-musleabihf": "4.62.2", - "@rollup/rollup-linux-arm64-gnu": "4.62.2", - "@rollup/rollup-linux-arm64-musl": "4.62.2", - "@rollup/rollup-linux-loong64-gnu": "4.62.2", - "@rollup/rollup-linux-loong64-musl": "4.62.2", - "@rollup/rollup-linux-ppc64-gnu": "4.62.2", - "@rollup/rollup-linux-ppc64-musl": "4.62.2", - "@rollup/rollup-linux-riscv64-gnu": "4.62.2", - "@rollup/rollup-linux-riscv64-musl": "4.62.2", - "@rollup/rollup-linux-s390x-gnu": "4.62.2", - "@rollup/rollup-linux-x64-gnu": "4.62.2", - "@rollup/rollup-linux-x64-musl": "4.62.2", - "@rollup/rollup-openbsd-x64": "4.62.2", - "@rollup/rollup-openharmony-arm64": "4.62.2", - "@rollup/rollup-win32-arm64-msvc": "4.62.2", - "@rollup/rollup-win32-ia32-msvc": "4.62.2", - "@rollup/rollup-win32-x64-gnu": "4.62.2", - "@rollup/rollup-win32-x64-msvc": "4.62.2", - "fsevents": "~2.3.2" + "@rolldown/binding-android-arm64": "1.1.5", + "@rolldown/binding-darwin-arm64": "1.1.5", + "@rolldown/binding-darwin-x64": "1.1.5", + "@rolldown/binding-freebsd-x64": "1.1.5", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", + "@rolldown/binding-linux-arm64-gnu": "1.1.5", + "@rolldown/binding-linux-arm64-musl": "1.1.5", + "@rolldown/binding-linux-ppc64-gnu": "1.1.5", + "@rolldown/binding-linux-s390x-gnu": "1.1.5", + "@rolldown/binding-linux-x64-gnu": "1.1.5", + "@rolldown/binding-linux-x64-musl": "1.1.5", + "@rolldown/binding-openharmony-arm64": "1.1.5", + "@rolldown/binding-wasm32-wasi": "1.1.5", + "@rolldown/binding-win32-arm64-msvc": "1.1.5", + "@rolldown/binding-win32-x64-msvc": "1.1.5" } }, "node_modules/safe-array-concat": { @@ -4677,6 +4357,14 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -4829,18 +4517,17 @@ } }, "node_modules/vite": { - "version": "7.3.6", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", - "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", + "version": "8.1.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz", + "integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==", "dev": true, "license": "MIT", "dependencies": { - "esbuild": "^0.27.0 || ^0.28.0", - "fdir": "^6.5.0", - "picomatch": "^4.0.3", - "postcss": "^8.5.6", - "rollup": "^4.43.0", - "tinyglobby": "^0.2.15" + "lightningcss": "^1.32.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.17", + "rolldown": "~1.1.5", + "tinyglobby": "^0.2.17" }, "bin": { "vite": "bin/vite.js" @@ -4856,9 +4543,10 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", - "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", @@ -4871,13 +4559,16 @@ "@types/node": { "optional": true }, - "jiti": { + "@vitejs/devtools": { "optional": true }, - "less": { + "esbuild": { + "optional": true + }, + "jiti": { "optional": true }, - "lightningcss": { + "less": { "optional": true }, "sass": { @@ -5150,6 +4841,29 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } } } } diff --git a/frontend/package.json b/frontend/package.json index b73daaf..2745747 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,25 +1,26 @@ { "name": "frontend", "private": true, - "version": "2.0.1", + "version": "2.1.0", "type": "module", "scripts": { "dev": "vite", "build": "vite build", "preview": "vite preview", - "lint": "eslint src" + "lint": "eslint src", + "test": "node --test \"src/**/*.test.js\"" }, "devDependencies": { - "@vitejs/plugin-react": "^5.1.2", - "eslint": "^9.16.0", - "eslint-plugin-react": "^7.37.2", - "eslint-plugin-react-hooks": "^5.1.0", - "vite": "^7.2.4" + "@vitejs/plugin-react": "^6.0.4", + "eslint": "^9.39.5", + "eslint-plugin-react": "^7.37.5", + "eslint-plugin-react-hooks": "^7.1.1", + "vite": "^8.1.5" }, "dependencies": { "highlight.js": "^11.11.1", "qrcode": "^1.5.4", - "react": "^19.2.3", - "react-dom": "^19.2.3" + "react": "^19.2.8", + "react-dom": "^19.2.8" } } diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 2644e68..738b413 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,10 +1,9 @@ import React, {useEffect, useState} from 'react'; import {SessionProvider, useSession} from './context/SessionContext'; import {ToastProvider} from './context/ToastContext'; -import {ConfirmProvider, useConfirm} from './context/ConfirmContext'; +import {ConfirmProvider} from './context/ConfirmContext'; import {SessionEntry} from './components/SessionEntry'; import {ClipboardInterface} from './components/ClipboardInterface'; -import {getSession} from './utils/api'; import {initConfig} from './utils/config'; // The id in the URL, if the path looks like one. `/r//` raw links and @@ -15,49 +14,34 @@ function urlSessionId() { } function AppContent() { - const {sessionData, clearSession} = useSession(); - const confirm = useConfirm(); + const {sessionData} = useSession(); const [isReady, setIsReady] = useState(false); useEffect(() => { - (async () => { - try { - await initConfig(); - } catch (err) { - console.error('Failed to initialize:', err); - } - // A saved session plus a *different* id in the URL — scanning a new - // QR while an old session is still stored — is ambiguous. Without - // this the stored session always won, the URL was ignored, and the - // only thing the user saw was the old session reported as expired. - const newId = urlSessionId(); - if (sessionData && newId && newId !== sessionData.connection_id) { - const oldId = sessionData.connection_id; - const oldAlive = await getSession(oldId, sessionData.user_id).then(() => true, () => false); - const shown = oldAlive ? {oldId} : {oldId}; - const goNew = await confirm({ - title: 'Open the new connection?', - message: ( - <> - You already have connection {shown}{oldAlive ? '' : ' open but expired'}. - {' '}Open {newId} instead? - - ), - confirmText: 'Open new', - cancelText: 'Stay', - confirmStyle: 'primary', - }); - if (goNew) { - clearSession(); // SessionEntry joins from the URL on mount - } else { - window.history.replaceState({}, '', `/${oldId}`); - } - } - setIsReady(true); - })(); - // eslint-disable-next-line react-hooks/exhaustive-deps + initConfig() + .catch((err) => console.error('Failed to initialize:', err)) + .finally(() => setIsReady(true)); }, []); + // Back and forward move between connections now that the path decides what + // is open. Everything in the app writes history with replaceState, so this + // only ever fires for a navigation the user made. + useEffect(() => { + const reload = () => window.location.reload(); + window.addEventListener('popstate', reload); + return () => window.removeEventListener('popstate', reload); + }, []); + + // The address bar is the source of truth. A stored session opens only when + // the path already names it; at `/` it is offered as a Resume button rather + // than quietly taking over the route. + const pathId = urlSessionId(); + const inSession = Boolean(sessionData && pathId === sessionData.connection_id); + // Offered whenever the stored session is not the one on screen, not only at + // `/`. Following a friend's link to a session that has since expired left + // the entry page with an error and no way back to the session still held. + const resumeId = !inSession && sessionData ? sessionData.connection_id : null; + if (!isReady) { return (
@@ -68,10 +52,19 @@ function AppContent() { return (
+ {resumeId && ( + + )}
- {sessionData ? : } + {inSession ? : }
- {!sessionData && ( + {!inSession && (