From 1dcb1875e7a774503cb1a3bffe6a458a71033f15 Mon Sep 17 00:00:00 2001 From: xinshoutw Date: Wed, 12 Aug 2026 09:48:51 +0800 Subject: [PATCH 01/33] feat(Backend): allow custom connection IDs and drop confusable characters - exclude i, o, e, 0 and 1 from the connection ID alphabet so an ID can be read aloud or copied by eye without ambiguity - accept an optional connection_id on session create, validated against the same length and alphabet and rejected with 409 when already in use - expose the alphabet through /session/id-length so the client filters input against the server's rule instead of its own copy --- backend/app.py | 53 +++++++++++++++++++++++++++++++++------ backend/tests/test_api.py | 32 +++++++++++++++++++++++ 2 files changed, 78 insertions(+), 7 deletions(-) diff --git a/backend/app.py b/backend/app.py index 666e50d..9ba6ef5 100644 --- a/backend/app.py +++ b/backend/app.py @@ -44,6 +44,14 @@ 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")) +# i/o/e/0/1 are dropped from the id alphabet 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). An id exists to be dictated, so the alphabet +# is trimmed instead of the mistakes being tolerated. +CONNECTION_ID_EXCLUDED = "ioe01" +CONNECTION_ID_ALPHABET = "".join( + c for c in string.ascii_lowercase + string.digits 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")) @@ -213,6 +221,10 @@ class SessionInfo(BaseModel): 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): @@ -679,15 +691,25 @@ def new_member() -> tuple[str, str]: return secrets.token_urlsafe(32), uuid.uuid4().hex[:12] +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)}" + 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_ALPHABET max_possible = len(chars) ** CONNECTION_ID_LENGTH if len(sessions) >= max_possible: @@ -833,14 +855,16 @@ 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 generated by the server, plus the + alphabet they are drawn from, so the client can filter input against the + same rule instead of keeping its own copy of it. """ return api_response( HTTPStatus.OK, "Connection ID length retrieved", { "connection_id_length": CONNECTION_ID_LENGTH, + "connection_id_alphabet": CONNECTION_ID_ALPHABET, } ) @@ -854,12 +878,27 @@ 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, but + # the character set is not: it guards the filesystem path below. + 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( diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index f004b29..eb5b946 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -33,6 +33,38 @@ 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 appear in an id — 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 + + # Confusable character. + r = client.post("/api/v2/session/create", json={"connection_id": "e" + good[1:]}) + assert r.status_code == 400 + + # 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 + + def test_join_then_get_session_requires_membership(client): host = _create_session(client) cid = host["connection_id"] From a7017451c5c9739996247030d268d419c93324b1 Mon Sep 17 00:00:00 2001 From: xinshoutw Date: Wed, 12 Aug 2026 09:52:58 +0800 Subject: [PATCH 02/33] feat(Backend): publish sessions to a live public lobby - add a host-only is_public flag, off by default, toggled through /session/toggle_public and mirrored to members over the session socket - serve the five newest published sessions from /sessions/public and stream the same list over an unauthenticated /ws/lobby socket - push appearances and disappearances immediately; fold timestamp-only churn into the existing 2s flush loop - drop the persisted snapshot between tests so state stops leaking forward --- backend/app.py | 158 ++++++++++++++++++++++++++++++++++++++ backend/tests/conftest.py | 6 +- backend/tests/test_api.py | 75 ++++++++++++++++++ 3 files changed, 238 insertions(+), 1 deletion(-) diff --git a/backend/app.py b/backend/app.py index 9ba6ef5..86a7baa 100644 --- a/backend/app.py +++ b/backend/app.py @@ -216,6 +216,7 @@ class SessionInfo(BaseModel): blocks: list[Block] allow_join: bool allow_curl_upload: bool + is_public: bool host_id: str @@ -278,6 +279,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 @@ -331,6 +338,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. @@ -357,10 +369,18 @@ def quota_check(self, additional_bytes: int = 0) -> str | None: return f"Session storage quota exceeded ({MAX_SESSION_BYTES} bytes)" return None + def host_name(self) -> str: + """Name shown for this session in the public lobby.""" + return next((u.name for u in self.users.values() if u.is_host), "Clippy") + 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.""" @@ -459,6 +479,8 @@ def to_dict(self) -> dict: "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(), } @@ -477,7 +499,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 @@ -526,6 +554,50 @@ 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.host_name(), + "created_at": s.created_at.isoformat(), + "last_activity": s.last_activity.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 @@ -534,6 +606,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: @@ -794,6 +868,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.""" @@ -1010,6 +1087,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), ""), ) @@ -1181,6 +1259,55 @@ 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") + + 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", @@ -1954,6 +2081,37 @@ async def _remove_user_after_grace(connection_id: str, user_id: str): }) +@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}") async def websocket_endpoint(websocket: WebSocket, connection_id: str): """Live session feed. 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 eb5b946..8df7cc7 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -119,6 +119,81 @@ 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 + + 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 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 = { From b9bc6c33cd6134ad6300711655c31b9e5718c8e1 Mon Sep 17 00:00:00 2001 From: xinshoutw Date: Wed, 12 Aug 2026 09:54:12 +0800 Subject: [PATCH 03/33] feat(Frontend): wire the API client to the new session endpoints - send an optional connection_id on create and read the ID alphabet from /session/id-length instead of assuming a-z0-9 - add toggleSessionPublic and getPublicSessions - move the http-to-ws scheme swap into config so both sockets share it --- frontend/src/hooks/useWebSocket.js | 6 ++--- frontend/src/utils/api.js | 37 ++++++++++++++++++++++++++---- frontend/src/utils/config.js | 8 +++++++ 3 files changed, 43 insertions(+), 8 deletions(-) diff --git a/frontend/src/hooks/useWebSocket.js b/frontend/src/hooks/useWebSocket.js index 5f4ad56..bcefd35 100644 --- a/frontend/src/hooks/useWebSocket.js +++ b/frontend/src/hooks/useWebSocket.js @@ -1,5 +1,5 @@ import {useEffect, useRef, useState} from 'react'; -import {getBackendUrl} from '../utils/config'; +import {getWebSocketUrl} from '../utils/config'; const RECONNECT_BASE_MS = 1000; const RECONNECT_MAX_MS = 30000; @@ -53,9 +53,7 @@ export function useWebSocket(sessionId, userId, onMessage, onAuthRejected, onRes openedThisAttempt = false; - const apiUrl = getBackendUrl(); - const wsUrl = apiUrl.replace(/^https/, 'wss').replace(/^http/, 'ws'); - const fullWsUrl = `${wsUrl}/ws/${sessionId}`; + const fullWsUrl = `${getWebSocketUrl()}/ws/${sessionId}`; // The member token goes in the subprotocol, not the path: browsers // can't set headers on a WS handshake, and a token in the URL lands diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js index 0a033c2..673d4a3 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -44,17 +44,26 @@ async function handleApiResponse(response) { return json; } -export async function getConnectionIdLength() { +/** + * Length and allowed characters for a connection ID, straight from the server + * so the client never keeps a second copy of the rule. + * + * @returns {Promise<{length: number, alphabet: string}>} + */ +export async function getConnectionIdRules() { const response = await fetch(`${getApiBase()}/session/id-length`); const data = await handleApiResponse(response); - return data.connection_id_length; + return { + length: data.connection_id_length, + alphabet: data.connection_id_alphabet ?? 'abcdefghijklmnopqrstuvwxyz0123456789', + }; } -export async function createSession(userName) { +export async function createSession(userName, connectionId) { const response = await fetch(`${getApiBase()}/session/create`, { method: 'POST', headers: {'Content-Type': 'application/json'}, - body: JSON.stringify({user_name: userName}), + body: JSON.stringify({user_name: userName, connection_id: connectionId || null}), }); return handleApiResponse(response); } @@ -123,6 +132,26 @@ export async function toggleCurl(sessionId, userId, allowCurlUpload) { return handleApiResponse(response); } +export async function toggleSessionPublic(sessionId, userId, isPublic) { + const response = await fetch(`${getApiBase()}/session/toggle_public`, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ + connection_id: sessionId, + user_id: userId, + is_public: isPublic, + }), + }); + return handleApiResponse(response); +} + +/** Snapshot of the public lobby; live updates come over the lobby socket. */ +export async function getPublicSessions() { + const response = await fetch(`${getApiBase()}/sessions/public`); + const data = await handleApiResponse(response); + return data.sessions ?? []; +} + export async function createTextBlock(sessionId, userId, content) { const encryptedContent = await encrypt(content); const response = await fetch(`${getApiBase()}/block/create`, { diff --git a/frontend/src/utils/config.js b/frontend/src/utils/config.js index d09b2b5..d9dc200 100644 --- a/frontend/src/utils/config.js +++ b/frontend/src/utils/config.js @@ -43,3 +43,11 @@ export function getBackendUrl() { } return backendUrl; } + +/** + * Backend URL with the scheme swapped for WebSockets. Anchored to the scheme so + * a host containing "http" later in the URL is left alone. + */ +export function getWebSocketUrl() { + return getBackendUrl().replace(/^https/, 'wss').replace(/^http/, 'ws'); +} From 59c437eb504b9337d8d053b4c20f49c10d69c2ba Mon Sep 17 00:00:00 2001 From: xinshoutw Date: Wed, 12 Aug 2026 09:57:25 +0800 Subject: [PATCH 04/33] feat(Frontend): add custom IDs, a remembered name and the public lobby - let New request its own connection ID, filtered to the server's alphabet as it is typed, with the rule in the label so New and Join stay the same height - keep the user name in local storage and reuse it every visit, an empty field included, so clearing it means "assign me one" rather than reverting - list the five newest public Clippys under the form with name, created, ID and last update, fed by the lobby socket so rooms appear and vanish live --- frontend/src/components/PublicSessions.css | 99 +++++++++++++++ frontend/src/components/PublicSessions.jsx | 137 +++++++++++++++++++++ frontend/src/components/SessionEntry.jsx | 101 ++++++++++++--- 3 files changed, 322 insertions(+), 15 deletions(-) create mode 100644 frontend/src/components/PublicSessions.css create mode 100644 frontend/src/components/PublicSessions.jsx diff --git a/frontend/src/components/PublicSessions.css b/frontend/src/components/PublicSessions.css new file mode 100644 index 0000000..dfd1b67 --- /dev/null +++ b/frontend/src/components/PublicSessions.css @@ -0,0 +1,99 @@ +.lobby { + display: flex; + flex-direction: column; + gap: 10px; +} + +.lobby-title { + display: flex; + align-items: center; + gap: 8px; + font-family: var(--font-mono); + font-size: 12px; + font-weight: 500; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--fg-subtle); +} + +.lobby-title::after { + content: ''; + flex: 1; + height: 1px; + background: var(--border); +} + +.lobby-list { + display: flex; + flex-direction: column; + gap: 6px; + list-style: none; +} + +.lobby-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 4px 12px; + width: 100%; + padding: 10px 12px; + text-align: left; + background: var(--bg-elev); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + transition: border-color 120ms var(--ease), background 120ms var(--ease); +} + +.lobby-row:hover:not(:disabled) { + border-color: var(--border-strong); +} + +.lobby-row:active:not(:disabled) { + background: var(--bg); +} + +.lobby-row:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.lobby-name { + font-size: 14px; + font-weight: 500; + color: var(--fg); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.lobby-id { + font-family: var(--font-mono); + font-size: 12px; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--fg-muted); + padding: 2px 6px; + border: 1px solid var(--border); + border-radius: 4px; + background: var(--bg); +} + +.lobby-meta { + grid-column: 1 / -1; + display: flex; + flex-wrap: wrap; + gap: 2px 14px; + font-size: 12px; + color: var(--fg-subtle); +} + +.lobby-time { + display: inline-flex; + gap: 5px; + font-variant-numeric: tabular-nums; +} + +.lobby-label { + color: var(--fg-muted); + opacity: 0.75; +} diff --git a/frontend/src/components/PublicSessions.jsx b/frontend/src/components/PublicSessions.jsx new file mode 100644 index 0000000..65bd9e9 --- /dev/null +++ b/frontend/src/components/PublicSessions.jsx @@ -0,0 +1,137 @@ +import React, {useEffect, useRef, useState} from 'react'; +import {getPublicSessions} from '../utils/api'; +import {getWebSocketUrl} from '../utils/config'; +import './PublicSessions.css'; + +const RECONNECT_MS = 3000; +// Relative timestamps go stale on their own, so re-render them on a slow tick +// rather than waiting for the server to push an update the room may never make. +const TICK_MS = 30000; + +/** + * Live list of sessions their host has published. + * + * The socket carries the whole list on every change, so there is no merge step + * — appearances and disappearances land as a straight replacement. + */ +function useLobby() { + const [sessions, setSessions] = useState([]); + + useEffect(() => { + let cancelled = false; + let socket = null; + let retryTimer = null; + // The REST snapshot is only a fallback for when the socket can't open; + // it must never overwrite a list the socket already delivered. + let live = false; + + getPublicSessions() + .then((initial) => { + if (!cancelled && !live) setSessions(initial); + }) + .catch(() => {}); + + const connect = () => { + if (cancelled) return; + socket = new WebSocket(`${getWebSocketUrl()}/ws/lobby`); + + socket.onmessage = (event) => { + let message; + try { + message = JSON.parse(event.data); + } catch { + return; + } + if (message.type === 'public_sessions') { + live = true; + setSessions(message.sessions ?? []); + } + }; + + socket.onclose = () => { + if (!cancelled) retryTimer = setTimeout(connect, RECONNECT_MS); + }; + }; + + connect(); + + return () => { + cancelled = true; + clearTimeout(retryTimer); + if (socket) { + socket.onclose = null; + socket.close(); + } + }; + }, []); + + return sessions; +} + +function useTick(intervalMs) { + const [, setTick] = useState(0); + useEffect(() => { + const id = setInterval(() => setTick((n) => n + 1), intervalMs); + return () => clearInterval(id); + }, [intervalMs]); +} + +function formatCreated(iso) { + const date = new Date(iso); + const time = date.toLocaleTimeString([], {hour: '2-digit', minute: '2-digit'}); + if (date.toDateString() === new Date().toDateString()) return time; + return `${date.toLocaleDateString([], {month: 'short', day: '2-digit'})} ${time}`; +} + +function formatRelative(iso) { + const seconds = Math.max(0, Math.round((Date.now() - new Date(iso).getTime()) / 1000)); + if (seconds < 60) return 'just now'; + const minutes = Math.round(seconds / 60); + if (minutes < 60) return `${minutes} min ago`; + return `${Math.round(minutes / 60)} hr ago`; +} + +export function PublicSessions({onJoin, disabled}) { + const sessions = useLobby(); + const joiningRef = useRef(null); + useTick(TICK_MS); + + // Nothing published means nothing to show — the section itself appears and + // disappears with the rooms. + if (sessions.length === 0) return null; + + return ( +
+

Public Clippys

+
    + {sessions.map((entry) => ( +
  • + +
  • + ))} +
+
+ ); +} diff --git a/frontend/src/components/SessionEntry.jsx b/frontend/src/components/SessionEntry.jsx index 52fb904..000bc7e 100644 --- a/frontend/src/components/SessionEntry.jsx +++ b/frontend/src/components/SessionEntry.jsx @@ -1,31 +1,60 @@ import React, {useEffect, useState} from 'react'; -import {createSession, getConnectionIdLength, joinSession} from '../utils/api'; +import {createSession, getConnectionIdRules, joinSession} from '../utils/api'; import {useSession} from '../context/SessionContext'; +import {PublicSessions} from './PublicSessions'; import './SessionEntry.css'; +const NAME_STORAGE_KEY = 'clippy_user_name'; +const DEFAULT_ID_RULES = {length: 6, alphabet: 'abcdefghijklmnopqrstuvwxyz0123456789'}; + function syncUrl(connectionId) { window.history.replaceState({}, '', `/${connectionId}`); } +// localStorage throws in private-mode Safari and when storage is disabled, and +// a throw in a useState initializer leaves a permanent white screen. +function readStoredName() { + try { + return localStorage.getItem(NAME_STORAGE_KEY) ?? ''; + } catch { + return ''; + } +} + +function storeName(name) { + try { + localStorage.setItem(NAME_STORAGE_KEY, name); + } catch { + /* nothing to do — the name is a convenience, not state we depend on */ + } +} + export function SessionEntry() { const [mode, setMode] = useState('create'); - const [userName, setUserName] = useState(''); + const [userName, setUserName] = useState(readStoredName); const [sessionId, setSessionId] = useState(''); + const [customId, setCustomId] = useState(''); const [error, setError] = useState(''); const [loading, setLoading] = useState(false); - const [idLength, setIdLength] = useState(6); + const [idRules, setIdRules] = useState(DEFAULT_ID_RULES); const {setSessionData} = useSession(); + // Persisted on every change, including when cleared: an empty field is a + // deliberate "give me a random name", not a reason to resurrect the old one. + useEffect(() => { + storeName(userName); + }, [userName]); + useEffect(() => { - getConnectionIdLength().then(setIdLength).catch(() => {}); + getConnectionIdRules().then(setIdRules).catch(() => {}); }, []); useEffect(() => { const pathname = window.location.pathname; const urlSessionId = pathname.replace('/', '').trim().toLowerCase(); - const pattern = new RegExp(`^[a-z0-9]{${idLength}}$`); - if (urlSessionId && urlSessionId.length === idLength && pattern.test(urlSessionId)) { + const pattern = new RegExp(`^[a-z0-9]{${idRules.length}}$`); + if (urlSessionId && urlSessionId.length === idRules.length && pattern.test(urlSessionId)) { setMode('join'); setSessionId(urlSessionId); setLoading(true); @@ -38,14 +67,14 @@ export function SessionEntry() { .catch((err) => setError(err.message)) .finally(() => setLoading(false)); } - }, [idLength, setSessionData]); + }, [idRules.length, setSessionData]); const handleCreate = async (e) => { e.preventDefault(); setLoading(true); setError(''); try { - const data = await createSession(userName || null); + const data = await createSession(userName || null, customId || null); syncUrl(data.connection_id); setSessionData(data); } catch (err) { @@ -55,12 +84,11 @@ export function SessionEntry() { } }; - const handleJoin = async (e) => { - e.preventDefault(); + const joinById = async (id) => { setLoading(true); setError(''); try { - const data = await joinSession(sessionId, userName || null); + const data = await joinSession(id, userName || null); syncUrl(data.connection_id); setSessionData(data); } catch (err) { @@ -70,7 +98,23 @@ export function SessionEntry() { } }; - const placeholder = '_'.repeat(idLength); + const handleJoin = (e) => { + e.preventDefault(); + joinById(sessionId); + }; + + // A chosen ID has to be one the server will accept, so drop anything outside + // its alphabet as it is typed. The join field stays lenient by comparison: + // an ID minted before those characters were retired must still be reachable. + const sanitizeCustomId = (value) => value + .toLowerCase() + .split('') + .filter((c) => idRules.alphabet.includes(c)) + .join('') + .slice(0, idRules.length); + + const placeholder = '_'.repeat(idRules.length); + const customIdIncomplete = customId.length > 0 && customId.length !== idRules.length; return (
@@ -113,7 +157,32 @@ export function SessionEntry() { disabled={loading} />
- @@ -128,7 +197,7 @@ export function SessionEntry() { value={sessionId} onChange={(e) => setSessionId(e.target.value.toLowerCase())} placeholder={placeholder} - maxLength={idLength} + maxLength={idRules.length} required disabled={loading} autoCapitalize="off" @@ -150,7 +219,7 @@ export function SessionEntry() { @@ -162,6 +231,8 @@ export function SessionEntry() { Error — {error} )} + + ); } From ab4cadd51a993f36c14bc3b724ec5357979d10eb Mon Sep 17 00:00:00 2001 From: xinshoutw Date: Wed, 12 Aug 2026 09:59:32 +0800 Subject: [PATCH 05/33] feat(Frontend): add a visibility lock beside the QR button - padlock toggle next to the QR code, locked by default, that publishes the session to the home page and turns green while it is listed - non-hosts see the current state but cannot change it, matching the other session switches - follow public_changed over the socket so every member sees the flip --- .../src/components/ClipboardInterface.jsx | 31 +++++++++++++++- frontend/src/components/Id.css | 12 +++++- frontend/src/components/Id.jsx | 37 ++++++++++++++++++- 3 files changed, 76 insertions(+), 4 deletions(-) diff --git a/frontend/src/components/ClipboardInterface.jsx b/frontend/src/components/ClipboardInterface.jsx index 1101ef2..7c053b1 100644 --- a/frontend/src/components/ClipboardInterface.jsx +++ b/frontend/src/components/ClipboardInterface.jsx @@ -3,7 +3,15 @@ import {useSession} from '../context/SessionContext'; import {useToast} from '../context/ToastContext'; import {useConfirm} from '../context/ConfirmContext'; import {useWebSocket} from '../hooks/useWebSocket'; -import {createTextBlock, deleteBlock, getSession, replaceFileBlock, updateTextBlock, uploadFileBlock} from '../utils/api'; +import { + createTextBlock, + deleteBlock, + getSession, + replaceFileBlock, + toggleSessionPublic, + updateTextBlock, + uploadFileBlock, +} from '../utils/api'; import {clearSessionKey, setSessionKeyFromConnectionId} from '../utils/encryption'; import {SUPPORTED_LANGUAGES, encodeCodeBlock} from '../utils/codeBlock'; import {BlockItem} from './BlockItem'; @@ -146,6 +154,10 @@ export function ClipboardInterface() { setSession((prev) => ({...prev, allow_curl_upload: message.allow_curl_upload})); break; + case 'public_changed': + setSession((prev) => ({...prev, is_public: message.is_public})); + break; + case 'session_destroyed': showNotification('Connection destroyed'); setTimeout(() => { @@ -226,6 +238,16 @@ export function ClipboardInterface() { } }; + const handleToggleVisibility = async () => { + const next = !session?.is_public; + try { + await toggleSessionPublic(sessionData.connection_id, sessionData.user_id, next); + toast.success(next ? 'Listed on the home page' : 'Private again'); + } catch (err) { + toast.error('Failed to change visibility: ' + err.message); + } + }; + const handleLogoClick = async () => { const confirmed = await confirm({ title: 'Leave connection', @@ -280,7 +302,12 @@ export function ClipboardInterface() { )}
- +
diff --git a/frontend/src/components/Id.css b/frontend/src/components/Id.css index 110862c..779fc1b 100644 --- a/frontend/src/components/Id.css +++ b/frontend/src/components/Id.css @@ -31,11 +31,21 @@ transition: color 120ms var(--ease), border-color 120ms var(--ease); } -.id-btn:hover { +.id-btn:hover:not(:disabled) { color: var(--fg); border-color: var(--border-strong); } +.id-btn:disabled { + cursor: default; +} + +/* Public is the loud state: an unlocked room is worth noticing at a glance. */ +.id-btn.is-public { + color: var(--accent-live); + border-color: var(--accent-live); +} + .qr-popup { position: absolute; top: calc(100% + 8px); diff --git a/frontend/src/components/Id.jsx b/frontend/src/components/Id.jsx index f2c76cf..81c9a97 100644 --- a/frontend/src/components/Id.jsx +++ b/frontend/src/components/Id.jsx @@ -13,7 +13,27 @@ function readQrColors() { return {dark: fg, light: bg}; } -export function Id({sessionData}) { +// Body plus shackle: closed sits square on the box, open swings clear of it. +const IconLock = ({open}) => ( + +); + +export function Id({sessionData, isPublic = false, canToggleVisibility = false, onToggleVisibility}) { const [showQrPopup, setShowQrPopup] = useState(false); const [qrDataUrl, setQrDataUrl] = useState(''); const [qrError, setQrError] = useState(false); @@ -92,6 +112,21 @@ export function Id({sessionData}) { + {showQrPopup && (
From c47f1369fa4a75aa4fb5c9208f3daeac54cbb0f2 Mon Sep 17 00:00:00 2001 From: xinshoutw Date: Wed, 12 Aug 2026 10:01:35 +0800 Subject: [PATCH 06/33] feat(Frontend): upload files dropped anywhere on the session page - window-level drag handlers so a drop outside the card uploads instead of navigating the tab to the file - upload dropped files one after another, each held in memory whole while it is encrypted, and report per-file failures - overlay shows the drop hint and the remaining count, with pointer events off so the composer's own drop zone still works --- .../src/components/ClipboardInterface.css | 27 +++++++ .../src/components/ClipboardInterface.jsx | 80 +++++++++++++++++++ 2 files changed, 107 insertions(+) diff --git a/frontend/src/components/ClipboardInterface.css b/frontend/src/components/ClipboardInterface.css index 9915b80..406ac67 100644 --- a/frontend/src/components/ClipboardInterface.css +++ b/frontend/src/components/ClipboardInterface.css @@ -405,3 +405,30 @@ font-family: var(--font-mono); color: var(--fg-subtle); } + +/* Drops are caught on the window, so the hint covers the viewport rather than + the card. pointer-events stays off: the composer's own drop zone has to keep + receiving the drop when the cursor is over it. */ +.desk-drop { + position: fixed; + inset: 0; + z-index: 60; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + pointer-events: none; + background: var(--bg); + background: color-mix(in srgb, var(--bg) 72%, transparent); +} + +.desk-drop-card { + padding: 18px 26px; + font-size: 15px; + font-weight: 500; + color: var(--fg); + background: var(--bg-elev); + border: 1.5px dashed var(--border-strong); + border-radius: var(--radius); + box-shadow: var(--shadow); +} diff --git a/frontend/src/components/ClipboardInterface.jsx b/frontend/src/components/ClipboardInterface.jsx index 7c053b1..a6194d4 100644 --- a/frontend/src/components/ClipboardInterface.jsx +++ b/frontend/src/components/ClipboardInterface.jsx @@ -35,6 +35,8 @@ export function ClipboardInterface() { const [notification, setNotification] = useState(null); const [isCreating, setIsCreating] = useState(false); const [newBlockType, setNewBlockType] = useState('text'); + const [isDraggingFiles, setIsDraggingFiles] = useState(false); + const [pendingUploads, setPendingUploads] = useState(0); useEffect(() => { if (!sessionData?.connection_id) return; @@ -219,6 +221,72 @@ export function ClipboardInterface() { } }; + // Dropped files skip the composer entirely: they upload one after another + // rather than in parallel, because each one is held in memory whole while + // it is encrypted. + const uploadDroppedFiles = useCallback(async (fileList) => { + const files = Array.from(fileList || []); + if (files.length === 0) return; + + setPendingUploads(files.length); + let uploaded = 0; + for (const file of files) { + try { + await uploadFileBlock(sessionData.connection_id, sessionData.user_id, file); + uploaded += 1; + } catch (err) { + toast.error(`Failed to upload ${file.name}: ${err.message}`); + } + setPendingUploads(files.length - uploaded); + } + setPendingUploads(0); + if (uploaded > 0) { + toast.success(uploaded === 1 ? 'Uploaded' : `Uploaded ${uploaded} files`); + } + }, [sessionData, toast]); + + // Bound to the window, not the layout: a file dropped just outside the card + // would otherwise be opened by the browser and navigate the session away. + useEffect(() => { + const carriesFiles = (e) => Array.from(e.dataTransfer?.types || []).includes('Files'); + // dragenter/dragleave fire for every child element, so track depth + // instead of toggling — otherwise the overlay strobes as the cursor moves. + let depth = 0; + + const onDragEnter = (e) => { + if (!carriesFiles(e)) return; + depth += 1; + setIsDraggingFiles(true); + }; + const onDragOver = (e) => { + if (!carriesFiles(e)) return; + e.preventDefault(); + }; + const onDragLeave = (e) => { + if (!carriesFiles(e)) return; + depth = Math.max(0, depth - 1); + if (depth === 0) setIsDraggingFiles(false); + }; + const onDrop = (e) => { + if (!carriesFiles(e)) return; + e.preventDefault(); + depth = 0; + setIsDraggingFiles(false); + uploadDroppedFiles(e.dataTransfer.files); + }; + + window.addEventListener('dragenter', onDragEnter); + window.addEventListener('dragover', onDragOver); + window.addEventListener('dragleave', onDragLeave); + window.addEventListener('drop', onDrop); + return () => { + window.removeEventListener('dragenter', onDragEnter); + window.removeEventListener('dragover', onDragOver); + window.removeEventListener('dragleave', onDragLeave); + window.removeEventListener('drop', onDrop); + }; + }, [uploadDroppedFiles]); + const handleReplaceFile = async (blockId, file) => { try { await replaceFileBlock(sessionData.connection_id, sessionData.user_id, blockId, file); @@ -384,6 +452,16 @@ export function ClipboardInterface() { )}
+ {(isDraggingFiles || pendingUploads > 0) && ( +
+
+ {pendingUploads > 0 + ? `Uploading… ${pendingUploads} left` + : 'Drop files to upload'} +
+
+ )} + {notification && } ); @@ -487,8 +565,10 @@ function FileUploadForm({onSubmit}) { } }; + // stopPropagation, or the window-level drop handler uploads the file too. const handleDrop = (e) => { e.preventDefault(); + e.stopPropagation(); setDragging(false); if (isUploading) return; if (e.dataTransfer.files?.[0]) setFile(e.dataTransfer.files[0]); From 38c2882b5ca32f70fc226bd5cd5d5fb6e31c84dd Mon Sep 17 00:00:00 2001 From: xinshoutw Date: Wed, 12 Aug 2026 10:03:58 +0800 Subject: [PATCH 07/33] feat(Frontend): show thumbnails for uploaded images - decrypt image blocks in the tab and paint them from an object URL, typed from the file extension since the stored bytes carry no content type - cap the preview at 12 MB so a large upload is downloaded, not rendered - render through only, never a link to the blob, so an uploaded SVG cannot run as a document on this origin - allow blob: in the nginx img-src for the object URLs --- docker/nginx/default.conf | 4 +- frontend/src/components/BlockItem.css | 12 ++++++ frontend/src/components/BlockItem.jsx | 58 +++++++++++++++++++++++++++ 3 files changed, 73 insertions(+), 1 deletion(-) diff --git a/docker/nginx/default.conf b/docker/nginx/default.conf index 300a8dd..90e32b6 100644 --- a/docker/nginx/default.conf +++ b/docker/nginx/default.conf @@ -30,7 +30,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; diff --git a/frontend/src/components/BlockItem.css b/frontend/src/components/BlockItem.css index 3d09ba9..e3d58e6 100644 --- a/frontend/src/components/BlockItem.css +++ b/frontend/src/components/BlockItem.css @@ -297,3 +297,15 @@ .block-btn.is-primary:hover:not(:disabled) { opacity: 0.88; } + +.block-thumb { + display: block; + max-width: min(100%, 320px); + max-height: 220px; + width: auto; + height: auto; + object-fit: contain; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--bg); +} diff --git a/frontend/src/components/BlockItem.jsx b/frontend/src/components/BlockItem.jsx index 769e495..f687f6d 100644 --- a/frontend/src/components/BlockItem.jsx +++ b/frontend/src/components/BlockItem.jsx @@ -51,6 +51,32 @@ const IconRaw = (props) => ( ); +// Uploads are stored as opaque ciphertext with no content type, so the file +// name is all there is to go on — and a Blob needs a real type or the browser +// refuses to paint it. +const IMAGE_MIME_BY_EXT = { + png: 'image/png', + jpg: 'image/jpeg', + jpeg: 'image/jpeg', + gif: 'image/gif', + webp: 'image/webp', + avif: 'image/avif', + bmp: 'image/bmp', + ico: 'image/x-icon', + svg: 'image/svg+xml', +}; +// A thumbnail costs a full download plus a full decrypt in the tab, so past +// this size the download button is the better deal. Raise it together with +// lazy loading (IntersectionObserver) if rooms full of large images show up. +const IMAGE_PREVIEW_MAX_BYTES = 12 * 1024 * 1024; + +function imageMimeFor(block) { + if (block.type !== 'file') return null; + const name = block.original_filename || block.filename || ''; + const ext = name.includes('.') ? name.split('.').pop().toLowerCase() : ''; + return IMAGE_MIME_BY_EXT[ext] ?? null; +} + function looksLikeCode(text) { if (!text || text.length < 12) return false; return CODE_SIGNATURE.test(text); @@ -111,6 +137,7 @@ export function BlockItem({block, sessionId, userId, onDelete, onUpdateText, onR const [isSaving, setIsSaving] = useState(false); const [isReplacing, setIsReplacing] = useState(false); const [isGeneratingRaw, setIsGeneratingRaw] = useState(false); + const [previewUrl, setPreviewUrl] = useState(''); const fileInputRef = useRef(null); const toast = useToast(); @@ -132,6 +159,32 @@ export function BlockItem({block, sessionId, userId, onDelete, onUpdateText, onR return () => { cancelled = true; }; }, [block]); + const imageMime = useMemo(() => imageMimeFor(block), [block]); + + useEffect(() => { + if (!imageMime || block.size > IMAGE_PREVIEW_MAX_BYTES) return undefined; + let cancelled = false; + let objectUrl = ''; + + (async () => { + try { + const ciphertext = await fetchBlockCiphertext(sessionId, block.id, userId); + const bytes = await decryptToBytes(ciphertext); + if (cancelled) return; + objectUrl = URL.createObjectURL(new Blob([bytes], {type: imageMime})); + setPreviewUrl(objectUrl); + } catch (err) { + console.error('Preview failed:', err); + } + })(); + + return () => { + cancelled = true; + setPreviewUrl(''); + if (objectUrl) URL.revokeObjectURL(objectUrl); + }; + }, [block, imageMime, sessionId, userId]); + const parsed = useMemo(() => parseBlockContent(decryptedContent), [decryptedContent]); const rendered = useMemo(() => { @@ -378,6 +431,11 @@ export function BlockItem({block, sessionId, userId, onDelete, onUpdateText, onR ) : (
{parsed.body}
) + ) : previewUrl ? ( + /* An never executes script, so an uploaded SVG stays + inert here. Deliberately not a link to the blob: opening + one as a document would run it on this origin. */ + {title} ) : null} From 2418e7906605296a79e9d8512a8111409598ced7 Mon Sep 17 00:00:00 2001 From: xinshoutw Date: Wed, 12 Aug 2026 10:05:27 +0800 Subject: [PATCH 08/33] fix(Frontend): return to the dashboard silently when a connection dies - expiry, a destroyed session and a socket that stopped reconnecting all go straight home instead of raising a dialog or a toast first - skip the "open the new connection?" prompt when the stored session is already gone; the URL simply wins - clear the stored session synchronously, since every caller reloads right after and could otherwise outrun the effect that removes it --- frontend/src/App.jsx | 35 ++++++++++-------- .../src/components/ClipboardInterface.jsx | 37 +++++++------------ frontend/src/context/SessionContext.jsx | 8 ++++ 3 files changed, 41 insertions(+), 39 deletions(-) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 2644e68..ba66297 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -34,23 +34,28 @@ function AppContent() { 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) { + if (!oldAlive) { + // Nothing to weigh up: the stored session is gone, so the + // URL wins silently rather than announcing the expiry. clearSession(); // SessionEntry joins from the URL on mount } else { - window.history.replaceState({}, '', `/${oldId}`); + const goNew = await confirm({ + title: 'Open the new connection?', + message: ( + <> + You already have connection {oldId}. + {' '}Open {newId} instead? + + ), + confirmText: 'Open new', + cancelText: 'Stay', + confirmStyle: 'primary', + }); + if (goNew) { + clearSession(); + } else { + window.history.replaceState({}, '', `/${oldId}`); + } } } setIsReady(true); diff --git a/frontend/src/components/ClipboardInterface.jsx b/frontend/src/components/ClipboardInterface.jsx index a6194d4..891b988 100644 --- a/frontend/src/components/ClipboardInterface.jsx +++ b/frontend/src/components/ClipboardInterface.jsx @@ -81,25 +81,21 @@ export function ClipboardInterface() { } }; + // A dead connection is not a decision the user can help with, so there is + // nothing to ask: drop it and land on the dashboard. + const goHome = useCallback(() => { + clearSession(); + window.history.replaceState({}, '', '/'); + window.location.reload(); + }, [clearSession]); + useEffect(() => { const validateSession = async () => { if (!sessionData?.connection_id) return; try { await getSession(sessionData.connection_id, sessionData.user_id); } catch { - const shouldGoHome = await confirm({ - title: 'Connection expired', - message: 'Your connection has expired or is no longer available. Return to the home page?', - confirmText: 'Go home', - cancelText: 'Stay', - confirmStyle: 'primary' - }); - - if (shouldGoHome) { - clearSession(); - window.history.pushState({}, '', '/'); - window.location.reload(); - } + goHome(); } }; @@ -161,20 +157,13 @@ export function ClipboardInterface() { break; case 'session_destroyed': - showNotification('Connection destroyed'); - setTimeout(() => { - window.location.reload(); - }, 2000); + goHome(); break; } - }, [myPublicId]); + }, [myPublicId, goHome]); - const handleAuthRejected = useCallback(() => { - toast.error('Session no longer available — returning to home.'); - clearSession(); - window.history.replaceState({}, '', '/'); - setTimeout(() => window.location.reload(), 1200); - }, [clearSession, toast]); + // The socket gave up reconnecting — same treatment as an expired session. + const handleAuthRejected = goHome; // Passed by identity into a ref inside the hook, so re-creating it each // render only refreshes that ref — it never re-opens the socket. diff --git a/frontend/src/context/SessionContext.jsx b/frontend/src/context/SessionContext.jsx index 8d698ec..69258cd 100644 --- a/frontend/src/context/SessionContext.jsx +++ b/frontend/src/context/SessionContext.jsx @@ -24,6 +24,14 @@ export function SessionProvider({children}) { }, [sessionData]); const clearSession = () => { + // Cleared here as well as in the effect above: callers reload the page + // immediately after this, which can beat React's effect flush and leave + // the dead session in storage to be restored on the next load. + try { + localStorage.removeItem('clippy_session'); + } catch { + /* storage disabled — the effect below is the only other writer */ + } setSessionData(null); }; From a3a3905639fc3208dfbf6a03a3234758a1d961c3 Mon Sep 17 00:00:00 2001 From: xinshoutw Date: Wed, 12 Aug 2026 10:06:29 +0800 Subject: [PATCH 09/33] fix(Frontend): only leave a session when the server rejects it - attach the HTTP status to API errors - treat 404 and 403 as gone, and leave a session alone when the request never landed, so an offline moment no longer discards live blocks --- frontend/src/components/ClipboardInterface.jsx | 7 +++++-- frontend/src/utils/api.js | 15 ++++++++++++--- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/frontend/src/components/ClipboardInterface.jsx b/frontend/src/components/ClipboardInterface.jsx index 891b988..6b856ec 100644 --- a/frontend/src/components/ClipboardInterface.jsx +++ b/frontend/src/components/ClipboardInterface.jsx @@ -94,8 +94,11 @@ export function ClipboardInterface() { if (!sessionData?.connection_id) return; try { await getSession(sessionData.connection_id, sessionData.user_id); - } catch { - goHome(); + } catch (err) { + // Only a verdict from the server counts. An offline tab or a + // failed fetch has no status, and throwing away a session that + // is still alive would cost the user their blocks. + if (err.status === 404 || err.status === 403) goHome(); } }; diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js index 673d4a3..5fdc7ce 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -23,23 +23,32 @@ function authHeaders(userId) { return userId ? {Authorization: `Bearer ${userId}`} : {}; } +// Carries the HTTP status so callers can tell "the server said no" apart from +// "the request never landed" — a dropped connection must not be read as a +// session that no longer exists. +function apiError(message, status) { + const error = new Error(message); + error.status = status; + return error; +} + async function handleApiResponse(response) { let json; try { json = await response.json(); } catch { - throw new Error(`Invalid JSON response (HTTP ${response.status})`); + throw apiError(`Invalid JSON response (HTTP ${response.status})`, response.status); } if (json.status !== undefined) { if (json.status >= 200 && json.status < 300) { return json.data ?? json; } - throw new Error(json.message || 'Request failed'); + throw apiError(json.message || 'Request failed', json.status); } if (!response.ok) { - throw new Error(json.detail || json.message || 'Request failed'); + throw apiError(json.detail || json.message || 'Request failed', response.status); } return json; } From 292d2d2fa7da616637cddf6c939b250b623d0b56 Mon Sep 17 00:00:00 2001 From: xinshoutw Date: Wed, 12 Aug 2026 10:07:03 +0800 Subject: [PATCH 10/33] test(Backend): cover live lobby pushes over the WebSocket --- backend/tests/test_api.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 8df7cc7..1476843 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -157,6 +157,25 @@ def test_sessions_are_private_until_the_host_publishes_them(client): assert _public(client) == [] +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 From e7bed847e1cd0946d942cfc7ba2e0631cb2235b6 Mon Sep 17 00:00:00 2001 From: xinshoutw Date: Wed, 12 Aug 2026 10:08:48 +0800 Subject: [PATCH 11/33] fix(Frontend): keep the lobby list from breaking the entry page - retry quietly when the socket cannot be constructed at all - drop the per-row guard; the form's loading state already blocks a second join --- frontend/src/components/PublicSessions.jsx | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/frontend/src/components/PublicSessions.jsx b/frontend/src/components/PublicSessions.jsx index 65bd9e9..6efc191 100644 --- a/frontend/src/components/PublicSessions.jsx +++ b/frontend/src/components/PublicSessions.jsx @@ -1,4 +1,4 @@ -import React, {useEffect, useRef, useState} from 'react'; +import React, {useEffect, useState} from 'react'; import {getPublicSessions} from '../utils/api'; import {getWebSocketUrl} from '../utils/config'; import './PublicSessions.css'; @@ -33,7 +33,14 @@ function useLobby() { const connect = () => { if (cancelled) return; - socket = new WebSocket(`${getWebSocketUrl()}/ws/lobby`); + try { + socket = new WebSocket(`${getWebSocketUrl()}/ws/lobby`); + } catch { + // A lobby that cannot connect is a missing list, not a broken + // entry page — keep retrying quietly behind the form. + retryTimer = setTimeout(connect, RECONNECT_MS); + return; + } socket.onmessage = (event) => { let message; @@ -93,7 +100,6 @@ function formatRelative(iso) { export function PublicSessions({onJoin, disabled}) { const sessions = useLobby(); - const joiningRef = useRef(null); useTick(TICK_MS); // Nothing published means nothing to show — the section itself appears and @@ -110,11 +116,7 @@ export function PublicSessions({onJoin, disabled}) { type="button" className="lobby-row" disabled={disabled} - onClick={() => { - if (joiningRef.current === entry.connection_id) return; - joiningRef.current = entry.connection_id; - onJoin(entry.connection_id); - }} + onClick={() => onJoin(entry.connection_id)} > {entry.name} {entry.connection_id} From b140c491a001d038504669dfc6c775e11cc2df10 Mon Sep 17 00:00:00 2001 From: xinshoutw Date: Wed, 12 Aug 2026 10:15:51 +0800 Subject: [PATCH 12/33] fix(Frontend): correct two defects found while driving the UI - keep the lock green while hovered; the plain hover rule is more specific than the public rule and was repainting a listed session as private - drop maxLength from the custom ID field: it truncated a paste before the filter ran, so "Oi3E-x9k7m2" landed as "3x" instead of "3x9k7m" --- frontend/src/components/Id.css | 7 +++++-- frontend/src/components/SessionEntry.jsx | 5 ++++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/Id.css b/frontend/src/components/Id.css index 779fc1b..7e6f78c 100644 --- a/frontend/src/components/Id.css +++ b/frontend/src/components/Id.css @@ -40,8 +40,11 @@ cursor: default; } -/* Public is the loud state: an unlocked room is worth noticing at a glance. */ -.id-btn.is-public { +/* Public is the loud state: an unlocked room is worth noticing at a glance. + Listed alongside the hover rule so state, not the cursor, decides the colour + — the plain .id-btn:hover selector is the more specific of the two. */ +.id-btn.is-public, +.id-btn.is-public:hover:not(:disabled) { color: var(--accent-live); border-color: var(--accent-live); } diff --git a/frontend/src/components/SessionEntry.jsx b/frontend/src/components/SessionEntry.jsx index 000bc7e..3af2396 100644 --- a/frontend/src/components/SessionEntry.jsx +++ b/frontend/src/components/SessionEntry.jsx @@ -171,7 +171,10 @@ export function SessionEntry() { value={customId} onChange={(e) => setCustomId(sanitizeCustomId(e.target.value))} placeholder="Leave blank for random ID" - maxLength={idRules.length} + /* No maxLength: it would truncate a paste before the + filter runs, so "Oi3E-x9k7m2" would land as "3x" + instead of the six characters it actually holds. + sanitizeCustomId does the capping. */ disabled={loading} autoCapitalize="off" autoCorrect="off" From 4402d477fa8b064b0ff60372550f39e3d0827ce3 Mon Sep 17 00:00:00 2001 From: xinshoutw Date: Wed, 12 Aug 2026 10:18:17 +0800 Subject: [PATCH 13/33] refactor(Backend): label lobby rooms with the creator's name - fix the label at creation instead of reading the current host, so a listed room keeps its name through a host transfer and after everyone has left - fall back to the host in the loader for snapshots written without a name --- backend/app.py | 14 +++++++++----- backend/tests/test_api.py | 13 +++++++++++++ 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/backend/app.py b/backend/app.py index 86a7baa..e5bfffd 100644 --- a/backend/app.py +++ b/backend/app.py @@ -325,6 +325,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) } @@ -369,10 +373,6 @@ def quota_check(self, additional_bytes: int = 0) -> str | None: return f"Session storage quota exceeded ({MAX_SESSION_BYTES} bytes)" return None - def host_name(self) -> str: - """Name shown for this session in the public lobby.""" - return next((u.name for u in self.users.values() if u.is_host), "Clippy") - def update_activity(self): """Update the last activity timestamp to prevent session timeout.""" self.last_activity = datetime.now() @@ -473,6 +473,7 @@ 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()}, @@ -489,6 +490,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 @@ -572,7 +576,7 @@ def public_session_entries() -> list[dict]: entries = [ { "connection_id": s.connection_id, - "name": s.host_name(), + "name": s.name, "created_at": s.created_at.isoformat(), "last_activity": s.last_activity.isoformat(), } diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 1476843..9ae3748 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -147,12 +147,25 @@ def test_sessions_are_private_until_the_host_publishes_them(client): listed = _public(client) assert [e["connection_id"] for e in listed] == [cid] assert listed[0]["name"] == "Alice" + # The label is the creator's, so a host transfer must not rename the room. + 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)[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 + # Bob holds the session now, so taking it back down is his call, not Alice's. r = client.post("/api/v2/session/toggle_public", json={ "connection_id": cid, "user_id": host["user_id"], "is_public": False, }) + assert r.status_code == 403 + r = client.post("/api/v2/session/toggle_public", json={ + "connection_id": cid, "user_id": guest["user_id"], "is_public": False, + }) assert r.status_code == 200 assert _public(client) == [] From 9d7ce26e4e084282be3e4c04811dfd50e262c4ba Mon Sep 17 00:00:00 2001 From: xinshoutw Date: Wed, 12 Aug 2026 10:20:54 +0800 Subject: [PATCH 14/33] chore: bump version to 2.1.0 and document the new features - custom and confusion-free connection IDs - public Clippy listing on the entry page - drag-and-drop uploads and image thumbnails - remembered user name and silent return to the dashboard --- README.md | 12 +++++++----- README.zh-TW.md | 12 +++++++----- backend/app.py | 2 +- backend/pyproject.toml | 2 +- frontend/package-lock.json | 4 ++-- frontend/package.json | 2 +- 6 files changed, 19 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 0dd1e0d..47ef704 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. Pick your own ID or let the server mint one — either way `i`, `o`, `e`, `0` and `1` are left out, so an ID survives being read aloud or copied by eye +- **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 --- diff --git a/README.zh-TW.md b/README.zh-TW.md index 175feea..38125d8 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 或交由伺服器產生;兩者都不會出現 `i`、`o`、`e`、`0`、`1`,避免唸出來或用眼睛抄寫時認錯。 +- **公開 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。 --- diff --git a/backend/app.py b/backend/app.py index e5bfffd..57017df 100644 --- a/backend/app.py +++ b/backend/app.py @@ -146,7 +146,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, 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/frontend/package-lock.json b/frontend/package-lock.json index 72d217d..49294ee 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "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", diff --git a/frontend/package.json b/frontend/package.json index b73daaf..70a650d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "2.0.1", + "version": "2.1.0", "type": "module", "scripts": { "dev": "vite", From 3f1c2b9a3d63587e0e596f79b8ca27599c5dcbde Mon Sep 17 00:00:00 2001 From: xinshoutw Date: Wed, 12 Aug 2026 10:24:53 +0800 Subject: [PATCH 15/33] feat(Backend): restrict confusable characters to generated IDs only - keep i, o, e, 0 and 1 out of what the server mints, and accept them in an ID the caller names itself - validate a requested ID against the full a-z0-9 set, which is still the filesystem guard for the session directory --- backend/app.py | 28 +++++++++++++++++----------- backend/tests/test_api.py | 7 ++++--- 2 files changed, 21 insertions(+), 14 deletions(-) diff --git a/backend/app.py b/backend/app.py index 57017df..47f6a1a 100644 --- a/backend/app.py +++ b/backend/app.py @@ -44,13 +44,16 @@ 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")) -# i/o/e/0/1 are dropped from the id alphabet on purpose: they are the characters +# 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). An id exists to be dictated, so the alphabet -# is trimmed instead of the mistakes being tolerated. +# 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_ALPHABET = "".join( - c for c in string.ascii_lowercase + string.digits if c not in CONNECTION_ID_EXCLUDED +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. @@ -787,7 +790,7 @@ def generate_connection_id() -> str | None: capped attempt loop will find a free ID with overwhelming probability long before the cap is hit. """ - chars = CONNECTION_ID_ALPHABET + chars = CONNECTION_ID_GENERATED_ALPHABET max_possible = len(chars) ** CONNECTION_ID_LENGTH if len(sessions) >= max_possible: @@ -936,9 +939,10 @@ async def get_connection_id_length(): """ Get the configured connection ID length. - Returns the length of connection IDs generated by the server, plus the - alphabet they are drawn from, so the client can filter input against the - same rule instead of keeping its own copy of it. + 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, @@ -970,8 +974,10 @@ async def create_session(request: CreateSessionRequest): 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, but - # the character set is not: it guards the filesystem path below. + # 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) diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 9ae3748..a471f65 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -34,7 +34,7 @@ def test_create_session_returns_unique_id(client): def test_generated_ids_avoid_confusable_characters(client): - """i/o/e/0/1 must never appear in an id — they are the ones users misread.""" + """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 @@ -52,9 +52,10 @@ def test_custom_connection_id_is_honoured_and_validated(client): r = client.post("/api/v2/session/create", json={"connection_id": good}) assert r.status_code == 409 - # Confusable character. + # 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 == 400 + 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]}) From 29a1696335d4ace5f755bffbe7417ee8327f2b35 Mon Sep 17 00:00:00 2001 From: xinshoutw Date: Wed, 12 Aug 2026 10:28:20 +0800 Subject: [PATCH 16/33] feat(Frontend): rework the entry form and trim the lobby row - Connection ID sits at the top of both tabs and renders from one shared field component, so New and Join are the same markup and the same height - drop the hint text next to the label; the ID rule is no longer worth saying - filter the ID inputs on composition end as well as on change, so a Bopomofo keyboard commits nothing into a field that only accepts a-z0-9 - remove the "Last update" label and align its time to the right edge --- frontend/src/components/PublicSessions.css | 9 ++- frontend/src/components/PublicSessions.jsx | 3 +- frontend/src/components/SessionEntry.jsx | 94 +++++++++++----------- 3 files changed, 58 insertions(+), 48 deletions(-) diff --git a/frontend/src/components/PublicSessions.css b/frontend/src/components/PublicSessions.css index dfd1b67..ef01a59 100644 --- a/frontend/src/components/PublicSessions.css +++ b/frontend/src/components/PublicSessions.css @@ -81,7 +81,8 @@ .lobby-meta { grid-column: 1 / -1; display: flex; - flex-wrap: wrap; + align-items: baseline; + justify-content: space-between; gap: 2px 14px; font-size: 12px; color: var(--fg-subtle); @@ -93,6 +94,12 @@ font-variant-numeric: tabular-nums; } +/* Last update sits under the ID, hard against the right edge. */ +.lobby-time.is-end { + margin-left: auto; + text-align: right; +} + .lobby-label { color: var(--fg-muted); opacity: 0.75; diff --git a/frontend/src/components/PublicSessions.jsx b/frontend/src/components/PublicSessions.jsx index 6efc191..9b5513b 100644 --- a/frontend/src/components/PublicSessions.jsx +++ b/frontend/src/components/PublicSessions.jsx @@ -125,8 +125,7 @@ export function PublicSessions({onJoin, disabled}) { Created: {formatCreated(entry.created_at)} - - Last update: + {formatRelative(entry.last_activity)} diff --git a/frontend/src/components/SessionEntry.jsx b/frontend/src/components/SessionEntry.jsx index 3af2396..5472082 100644 --- a/frontend/src/components/SessionEntry.jsx +++ b/frontend/src/components/SessionEntry.jsx @@ -29,6 +29,36 @@ function storeName(name) { } } +/** + * The one connection-ID input, used by both tabs so New and Join cannot drift + * apart. `sanitize` runs on composition end as well as on change: an IME hands + * over its buffer without a plain input event, and dropping everything outside + * the alphabet is what keeps a Bopomofo keyboard from typing into this field. + */ +function ConnectionIdField({inputId, value, sanitize, onChange, placeholder, disabled, required}) { + const apply = (e) => onChange(sanitize(e.target.value)); + return ( +
+ + +
+ ); +} + export function SessionEntry() { const [mode, setMode] = useState('create'); const [userName, setUserName] = useState(readStoredName); @@ -103,10 +133,8 @@ export function SessionEntry() { joinById(sessionId); }; - // A chosen ID has to be one the server will accept, so drop anything outside - // its alphabet as it is typed. The join field stays lenient by comparison: - // an ID minted before those characters were retired must still be reachable. - const sanitizeCustomId = (value) => value + // Only what the server will accept survives being typed or pasted. + const sanitizeId = (value) => value .toLowerCase() .split('') .filter((c) => idRules.alphabet.includes(c)) @@ -146,6 +174,14 @@ export function SessionEntry() { {mode === 'create' ? (
+
-
- {/* The rule lives in the label, not a hint line below it, - so New and Join stay exactly the same height. */} - - setCustomId(sanitizeCustomId(e.target.value))} - placeholder="Leave blank for random ID" - /* No maxLength: it would truncate a paste before the - filter runs, so "Oi3E-x9k7m2" would land as "3x" - instead of the six characters it actually holds. - sanitizeCustomId does the capping. */ - disabled={loading} - autoCapitalize="off" - autoCorrect="off" - spellCheck="false" - /> -
+ )}
- {sessionData ? : } + {inSession ? : }
- {!sessionData && ( + {!inSession && (