feat!: remove direct messaging (chat)

E2EE direct messages, the chat dock, the Messages module, the message
WebSocket and its realtime registry, and the messages / message_keys
tables (migration 0097; the downgrade restores the empty schema). Prod
held two welcome notes, no message between people and one key — never
used (owner decision, E34). WebSocket now has no owner module: the lint
bans it everywhere. Privacy policy updated: it no longer promises E2EE
for a feature that is gone.
This commit is contained in:
2026-10-05 00:19:38 +02:00
parent 7f08c510bf
commit cca3315836
71 changed files with 122 additions and 3011 deletions
+1 -1
View File
@@ -63,7 +63,7 @@ how it gets built. Concretely:
`routes/*` (HTTP only: validation, auth-dependency, serialization) → domain packages `sync/`, `plex/`, `routes/*` (HTTP only: validation, auth-dependency, serialization) → domain packages `sync/`, `plex/`,
`downloads/`, `youtube/`, `issues/` (shared by routes + scheduler + worker) → flat core (`auth`, `quota`, `downloads/`, `youtube/`, `issues/` (shared by routes + scheduler + worker) → flat core (`auth`, `quota`,
`sysconfig`, `state`, `audit`, `notifications`, `userscope`, `realtime`, `ratelimit`, `bodylimit`, `progress`, `collation`, `sysconfig`, `state`, `audit`, `notifications`, `userscope`, `ratelimit`, `bodylimit`, `progress`, `collation`,
`security`, `logs`, `logsink`, `diagnostics`, `debugsession`) → `models/db/config`. **Dependency direction is one-way: core never imports routes.** `security`, `logs`, `logsink`, `diagnostics`, `debugsession`) → `models/db/config`. **Dependency direction is one-way: core never imports routes.**
Frontend: `main.tsx` provider tree → `App.tsx` shell → `moduleRegistry` route-chunks → module Frontend: `main.tsx` provider tree → `App.tsx` shell → `moduleRegistry` route-chunks → module
components → the **platform layer `components/ui/`** (domain-free primitives: the popover engine and components → the **platform layer `components/ui/`** (domain-free primitives: the popover engine and
-90
View File
@@ -824,96 +824,6 @@
} }
} }
], ],
"./app/routes/messages.py": [
{
"code": "reportCallIssue",
"range": {
"startColumn": 11,
"endColumn": 43,
"lineCount": 1
}
},
{
"code": "reportArgumentType",
"range": {
"startColumn": 23,
"endColumn": 27,
"lineCount": 1
}
},
{
"code": "reportCallIssue",
"range": {
"startColumn": 13,
"endColumn": 5,
"lineCount": 7
}
},
{
"code": "reportArgumentType",
"range": {
"startColumn": 8,
"endColumn": 15,
"lineCount": 5
}
},
{
"code": "reportReturnType",
"range": {
"startColumn": 15,
"endColumn": 72,
"lineCount": 1
}
},
{
"code": "reportCallIssue",
"range": {
"startColumn": 30,
"endColumn": 48,
"lineCount": 1
}
},
{
"code": "reportArgumentType",
"range": {
"startColumn": 41,
"endColumn": 44,
"lineCount": 1
}
},
{
"code": "reportOptionalMemberAccess",
"range": {
"startColumn": 36,
"endColumn": 38,
"lineCount": 1
}
},
{
"code": "reportOptionalMemberAccess",
"range": {
"startColumn": 71,
"endColumn": 73,
"lineCount": 1
}
},
{
"code": "reportArgumentType",
"range": {
"startColumn": 30,
"endColumn": 39,
"lineCount": 1
}
},
{
"code": "reportOptionalMemberAccess",
"range": {
"startColumn": 27,
"endColumn": 29,
"lineCount": 1
}
}
],
"./app/routes/playlists.py": [ "./app/routes/playlists.py": [
{ {
"code": "reportOptionalOperand", "code": "reportOptionalOperand",
@@ -0,0 +1,69 @@
"""messages: drop direct messaging (the owner's decision, 2026-10-04)
Chat — end-to-end encrypted direct messages plus the server's welcome note — was never used: prod held
two welcome notes, no message between people, and one key. Its tables go with its code. The rows are
not migrated anywhere; the nightly backup holds them.
The downgrade restores the schema as it stood (0026 + 0027), empty: a message can't be brought back
from a migration, and the E2EE key blobs were only ever readable by their owner's browser anyway.
"""
import sqlalchemy as sa
from alembic import op
revision: str = "0097_drop_messages"
down_revision: str | None = "0096_videos_sort_indexes"
branch_labels: str | None = None
depends_on: str | None = None
def upgrade() -> None:
op.drop_table("message_keys")
op.drop_index("ix_messages_created_at", table_name="messages")
op.drop_index("ix_messages_recipient_id", table_name="messages")
op.drop_index("ix_messages_sender_id", table_name="messages")
op.drop_table("messages")
def downgrade() -> None:
op.create_table(
"messages",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column(
"sender_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=True
),
sa.Column(
"recipient_id",
sa.Integer(),
sa.ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("body", sa.Text(), nullable=True),
sa.Column("read_at", sa.DateTime(timezone=True), nullable=True),
sa.Column(
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
),
sa.Column("kind", sa.String(length=16), nullable=False, server_default="user"),
sa.Column("ciphertext", sa.Text(), nullable=True),
sa.Column("iv", sa.String(length=32), nullable=True),
)
op.create_index("ix_messages_sender_id", "messages", ["sender_id"])
op.create_index("ix_messages_recipient_id", "messages", ["recipient_id"])
op.create_index("ix_messages_created_at", "messages", ["created_at"])
op.create_table(
"message_keys",
sa.Column(
"user_id",
sa.Integer(),
sa.ForeignKey("users.id", ondelete="CASCADE"),
primary_key=True,
),
sa.Column("public_key", sa.Text(), nullable=False),
sa.Column("wrapped_private_key", sa.Text(), nullable=False),
sa.Column("salt", sa.String(length=64), nullable=False),
sa.Column("wrap_iv", sa.String(length=32), nullable=False),
sa.Column("key_check", sa.Text(), nullable=False),
sa.Column(
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
),
)
+1 -1
View File
@@ -21,7 +21,7 @@ Three layers, by *who* runs the code — not by feature:
`issues/bundle.py` is the one definition of a bug report's files, for the zip export and the tracker. `issues/bundle.py` is the one definition of a bug report's files, for the zip export and the tracker.
3. **Flat `app/*.py` core modules — cross-cutting primitives.** `auth`, `sysconfig`, `quota`, `audit`, 3. **Flat `app/*.py` core modules — cross-cutting primitives.** `auth`, `sysconfig`, `quota`, `audit`,
`notifications`, `userscope`, `realtime`, `ratelimit`, `progress`, `logs` (the one logging `notifications`, `userscope`, `ratelimit`, `progress`, `logs` (the one logging
configuration), `logsink` (its `log_events` table), `diagnostics` (a user's consented log configuration), `logsink` (its `log_events` table), `diagnostics` (a user's consented log
level), `debugsession` (a recording that tags its user's lines, E16) and `collation` (A→Z in the level), `debugsession` (a recording that tags its user's lines, E16) and `collation` (A→Z in the
reader's alphabet, E30), … are single-file primitives many layers import. They depend on nothing above them (a core reader's alphabet, E30), … are single-file primitives many layers import. They depend on nothing above them (a core
+3 -4
View File
@@ -278,8 +278,7 @@ ACTIVE_ACCOUNT_HEADER = "x-siftlode-account"
def resolved_user_id(conn: HTTPConnection, *, strict: bool = True) -> tuple[int | None, bool]: def resolved_user_id(conn: HTTPConnection, *, strict: bool = True) -> tuple[int | None, bool]:
"""Which account this connection acts as, and whether that's the session's default account. """Which account this connection acts as, and whether that's the session's default account.
Takes any HTTPConnection — an HTTP Request OR a WebSocket — so the WS push channel shares this Takes an HTTPConnection rather than a Request: it reads only the session and the headers.
exact per-tab resolution instead of re-implementing it.
The signed cookie holds the *wallet* (`account_ids`, every account signed into this browser) The signed cookie holds the *wallet* (`account_ids`, every account signed into this browser)
plus a default `user_id`. A caller may name its account with the X-Siftlode-Account header (or plus a default `user_id`. A caller may name its account with the X-Siftlode-Account header (or
@@ -297,8 +296,8 @@ def resolved_user_id(conn: HTTPConnection, *, strict: bool = True) -> tuple[int
""" """
default_id = conn.session.get("user_id") default_id = conn.session.get("user_id")
wallet = conn.session.get("account_ids") or [] wallet = conn.session.get("account_ids") or []
# Header for normal XHR; ?account= for contexts that can't set headers (WebSocket, and a # Header for normal XHR; ?account= for a context that can't set headers (a plain <a> file
# plain <a> file download). Both are wallet-gated below. # download). Both are wallet-gated below.
hdr = conn.headers.get(ACTIVE_ACCOUNT_HEADER) or conn.query_params.get("account") hdr = conn.headers.get(ACTIVE_ACCOUNT_HEADER) or conn.query_params.get("account")
if hdr: if hdr:
try: try:
-1
View File
@@ -688,7 +688,6 @@ _POLLED_ROUTES = frozenset(
{ {
"/api/me", "/api/me",
"/api/me/notifications/unread_count", "/api/me/notifications/unread_count",
"/api/messages/unread_count",
"/api/downloads", "/api/downloads",
"/api/downloads/index", "/api/downloads/index",
"/api/downloads/usage", "/api/downloads/usage",
-2
View File
@@ -44,7 +44,6 @@ from app.routes import ( # noqa: E402 — logging is configured first
feed, feed,
health, health,
me, me,
messages,
notifications, notifications,
playlists, playlists,
plex as plex_routes, plex as plex_routes,
@@ -311,7 +310,6 @@ app.include_router(me.router)
app.include_router(diagnostics_routes.router) app.include_router(diagnostics_routes.router)
app.include_router(debug_sessions_routes.router) app.include_router(debug_sessions_routes.router)
app.include_router(notifications.router) app.include_router(notifications.router)
app.include_router(messages.router)
app.include_router(channels.router) app.include_router(channels.router)
app.include_router(playlists.router) app.include_router(playlists.router)
app.include_router(saved_views.router) app.include_router(saved_views.router)
+1 -62
View File
@@ -59,7 +59,7 @@ POSITION_MAX_S = 2**31 - 1
class TimestampMixin: class TimestampMixin:
"""Adds a server-defaulted `created_at`. Models that index created_at or order it """Adds a server-defaulted `created_at`. Models that index created_at or order it
differently (QuotaEvent, Notification, Message) keep their own column on purpose.""" differently (QuotaEvent, Notification) keep their own column on purpose."""
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
@@ -814,44 +814,6 @@ class Notification(Base):
) )
class Message(Base):
"""A message in the notification module (phase 2).
Two kinds share this table:
- `kind="user"`: a private, END-TO-END ENCRYPTED direct message between two users. The
server only ever stores `ciphertext` + `iv` (base64); it never holds the plaintext or
the keys, so not even an admin can read it. A conversation is just the set of messages
between two users — no separate thread entity; queries group by the {sender, recipient}
pair.
- `kind="system"`: a server-authored plaintext message (e.g. the welcome, or future admin
announcements). `sender_id` is NULL and `body` holds the text. These are non-private
boilerplate, readable without any E2EE key setup, and surface as a "Siftlode" conversation.
`read_at` is stamped when the recipient opens the thread (NULL = unread, feeds the nav
badge). The recipient FK cascades (and sender, for user messages) so a GDPR account erasure
removes that user's messages outright."""
__tablename__ = "messages"
id: Mapped[int] = mapped_column(primary_key=True)
kind: Mapped[str] = mapped_column(String(16), default="user", server_default="user")
# NULL for system messages (no human sender).
sender_id: Mapped[int | None] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), index=True
)
recipient_id: Mapped[int] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), index=True
)
# System messages carry plaintext `body`; user messages carry `ciphertext`+`iv` (base64).
body: Mapped[str | None] = mapped_column(Text)
ciphertext: Mapped[str | None] = mapped_column(Text)
iv: Mapped[str | None] = mapped_column(String(32))
read_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), index=True
)
class MediaAsset(Base, TimestampMixin): class MediaAsset(Base, TimestampMixin):
"""A physical downloaded media file — the SHARED cache layer of the download center. """A physical downloaded media file — the SHARED cache layer of the download center.
@@ -1094,29 +1056,6 @@ class DownloadCredential(Base, TimestampMixin, UpdatedAtMixin):
cookies_enc: Mapped[str] = mapped_column(Text) # Fernet ciphertext — never returned/logged cookies_enc: Mapped[str] = mapped_column(Text) # Fernet ciphertext — never returned/logged
class MessageKey(Base, TimestampMixin):
"""A user's end-to-end-encryption key material for direct messaging (phase 2).
One row per user. `public_key` (base64 SPKI, ECDH P-256) is shared with others so they can
derive the pairwise conversation key. `wrapped_private_key` is the user's private key
encrypted IN THE BROWSER with a key derived (PBKDF2) from a message passphrase the server
never sees — so the server stores only an opaque blob it cannot unwrap. `salt`/`wrap_iv`
parameterise that derivation; `key_check` is a small ciphertext the client decrypts to
verify a passphrase on unlock. The server is purely a key directory + blob store; it can
never read any message."""
__tablename__ = "message_keys"
user_id: Mapped[int] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), primary_key=True
)
public_key: Mapped[str] = mapped_column(Text)
wrapped_private_key: Mapped[str] = mapped_column(Text)
salt: Mapped[str] = mapped_column(String(64))
wrap_iv: Mapped[str] = mapped_column(String(32))
key_check: Mapped[str] = mapped_column(Text)
# --- Plex integration (optional) ------------------------------------------------------------- # --- Plex integration (optional) -------------------------------------------------------------
# A dedicated, self-contained catalog mirrored from a locally-reachable Plex server. Playback is # A dedicated, self-contained catalog mirrored from a locally-reachable Plex server. Playback is
# from the LOCAL physical file (Plex is metadata only); per-user watch state lives in plex_states # from the LOCAL physical file (Plex is metadata only); per-user watch state lives in plex_states
-68
View File
@@ -1,68 +0,0 @@
"""In-process WebSocket connection registry for live server→client push.
Generic groundwork (notification module phase 2 is the first user): connections are kept in
this process's memory, keyed by user_id, so a request handler can push an event to all of a
user's open tabs/devices. Single-uvicorn-process assumption — if we ever run multiple workers,
replace the in-memory map with a Redis pub/sub fan-out behind the same `push()` interface (same
note as app.ratelimit).
`push()` is intentionally SYNC-callable: ordinary (sync) route handlers run in a threadpool, so
they schedule the actual send onto the WebSocket event loop via run_coroutine_threadsafe. The
connect/disconnect/_push coroutines all run on that one loop, so the connection map is only ever
mutated from a single thread.
"""
import asyncio
from collections import defaultdict
from starlette.websockets import WebSocket
from app import logs
log = logs.get_logger(__name__)
class ConnectionManager:
def __init__(self) -> None:
self._conns: dict[int, set[WebSocket]] = defaultdict(set)
self._loop: asyncio.AbstractEventLoop | None = None
async def connect(self, user_id: int, ws: WebSocket) -> None:
self._loop = asyncio.get_running_loop()
await ws.accept()
self._conns[user_id].add(ws)
async def disconnect(self, user_id: int, ws: WebSocket) -> None:
conns = self._conns.get(user_id)
if conns:
conns.discard(ws)
if not conns:
self._conns.pop(user_id, None)
async def _push(self, user_id: int, payload: dict) -> None:
for ws in list(self._conns.get(user_id, ())):
try:
await ws.send_json(payload)
except Exception as exc: # noqa: BLE001 — one dead socket must not stop the others
# Stale socket; it'll be cleaned up by its own disconnect handler. But this is
# also where a live update goes missing: the UI simply never changes, and there
# is no request behind it whose failure anyone could look up.
log.debug(
"a live update could not be pushed to a socket",
extra=logs.event(
"realtime.push_failed", user_id=user_id, reason=type(exc).__name__
),
)
def push(self, user_id: int, payload: dict) -> None:
"""Schedule a push onto the WebSocket event loop. Safe to call from a sync route
handler (threadpool). No-op if no WebSocket has ever connected (no loop captured)."""
loop = self._loop
if loop is None:
return
try:
asyncio.run_coroutine_threadsafe(self._push(user_id, payload), loop)
except Exception: # noqa: BLE001 — a closed loop or socket must not fail the caller
log.debug("ws push failed", exc_info=True)
manager = ConnectionManager()
-479
View File
@@ -1,479 +0,0 @@
"""Direct messaging (notification module, phase 2): end-to-end encrypted user-to-user chat
plus server-authored system messages, with live WebSocket delivery.
E2EE model: every private message is encrypted IN THE BROWSER under a key both parties derive
from ECDH(their private key, the other's public key). The server only ever stores ciphertext +
iv and acts as a key directory (public keys) and an opaque blob store (each user's private key,
wrapped client-side with a passphrase the server never sees). So not even an admin can read a
user-to-user conversation. `kind="system"` messages (the welcome; future announcements) are
plain server boilerplate — non-private, readable without any key setup, shown as a "Siftlode"
conversation.
Every endpoint is gated by `require_human` (the shared demo account can't message). Metadata
(who messaged whom, when, read state) is NOT encrypted — standard for E2EE and needed for
routing and unread counts.
"""
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, WebSocket, WebSocketDisconnect
from pydantic import BaseModel
from sqlalchemy import and_, case, delete, func, or_, select
from sqlalchemy.orm import Session
from app.mailtext import lang_for_user
from app.collation import collated
from app.auth import require_human, resolved_user_id
from app.db import SessionLocal, get_db
from app.models import Message, MessageKey, User
from app.ratelimit import RateLimiter
from app.realtime import manager
from app.security import verify_password
from app.userscope import is_messageable_user, messageable_clauses
from app.utils import iso
from app.refusal import refused
from app.routes._common import not_found
router = APIRouter(prefix="/api/messages", tags=["messages"])
MAX_CIPHERTEXT = 8000 # base64 of a generous plaintext + AES-GCM overhead
SYSTEM_PARTNER_ID = 0 # synthetic conversation partner for system messages
SYSTEM_NAME = "Siftlode"
_send_limiter = RateLimiter(max_events=30, window_seconds=60)
_reset_key_limiter = RateLimiter(max_events=5, window_seconds=300)
# The welcome is rendered server-side at creation time, so it carries its own translations
# (one per supported UI language). Kept short and non-private (every new user gets the same text).
WELCOME = {
"en": (
"Welcome to Siftlode! 👋\n\n"
"This is your private message inbox. Conversations with other members are end-to-end "
"encrypted — only you and the person you're chatting with can read them, not even an "
"admin. To start messaging someone, set up secure messaging with a passphrase first.\n\n"
"Enjoy your Feed!"
),
"hu": (
"Üdv a Siftlode-on! 👋\n\n"
"Ez a privát üzenet-postafiókod. A többi taggal folytatott beszélgetések végpontig "
"titkosítottak — csak te és a beszélgetőpartnered olvashatja őket, még az admin sem. "
"Ha üzenni szeretnél valakinek, előbb állítsd be a biztonságos üzenetküldést egy "
"jelszóval.\n\n"
"Jó böngészést!"
),
}
class KeySetupIn(BaseModel):
public_key: str
wrapped_private_key: str
salt: str
wrap_iv: str
key_check: str
class KeyResetIn(BaseModel):
# Present only for password accounts; a Google-only account has no password to re-verify.
current_password: str | None = None
class SendIn(BaseModel):
recipient_id: int
ciphertext: str
iv: str
def _user_label(u: User) -> str:
return u.display_name or u.email.split("@")[0]
def _serialize_user(u: User) -> dict:
return {"id": u.id, "name": _user_label(u), "avatar_url": u.avatar_url}
def _system_partner() -> dict:
return {"id": SYSTEM_PARTNER_ID, "name": SYSTEM_NAME, "avatar_url": None}
# The welcome's opening line in every language — how a stored copy is recognised. It was rendered
# once, in the language of the day it was created, so an account that later switched language kept
# reading it in the old one (E31 S1: the owner's, in English). Recognised here, it is served in the
# reader's language instead. Derived from WELCOME itself, so a change to an OPENING line, or a
# language taken out of WELCOME, orphans the copies already stored: they are served as stored. The
# one retired language (German, 2026-07) left none on prod (checked 2026-10-02).
_WELCOME_OPENINGS = {text.partition("\n")[0] for text in WELCOME.values()}
def _system_body(body: str | None, lang: str | None) -> str | None:
"""A system message's text for a reader of `lang`: the welcome in their language, any other as
stored."""
if body is None or lang is None or body.partition("\n")[0] not in _WELCOME_OPENINGS:
return body
return WELCOME.get(lang, WELCOME["en"])
def _serialize_msg(m: Message, lang: str | None = None) -> dict:
"""`lang`: the reader's language, for a system message's text (a user message is ciphertext)."""
return {
"id": m.id,
"kind": m.kind,
"sender_id": m.sender_id,
"recipient_id": m.recipient_id,
"body": _system_body(m.body, lang) if m.kind == "system" else m.body,
"ciphertext": m.ciphertext,
"iv": m.iv,
"read_at": iso(m.read_at),
"created_at": iso(m.created_at),
}
def ensure_welcome(db: Session, user: User) -> None:
"""Create the one-time system welcome message for a (human) user the first time it's needed.
Idempotent: guarded on whether any system message already exists for them."""
if user.is_demo:
return
exists = db.scalar(
select(Message.id).where(Message.recipient_id == user.id, Message.kind == "system").limit(1)
)
if exists:
return
lang = (user.preferences or {}).get("language")
body = WELCOME.get(lang, WELCOME["en"])
m = Message(kind="system", sender_id=None, recipient_id=user.id, body=body)
db.add(m)
db.commit()
db.refresh(m)
manager.push(user.id, {"type": "message", "message": _serialize_msg(m, lang_for_user(user))})
# --- E2EE key directory / blob store -----------------------------------------
@router.get("/keys/me")
def my_key(user: User = Depends(require_human), db: Session = Depends(get_db)) -> dict:
"""My own key bundle. `configured` says whether secure messaging is set up; if so the
wrapped blob + params are returned so this device can unlock with the passphrase."""
k = db.get(MessageKey, user.id)
if k is None:
return {"configured": False}
return {
"configured": True,
"public_key": k.public_key,
"wrapped_private_key": k.wrapped_private_key,
"salt": k.salt,
"wrap_iv": k.wrap_iv,
"key_check": k.key_check,
}
@router.post("/keys")
def setup_keys(
payload: KeySetupIn,
user: User = Depends(require_human),
db: Session = Depends(get_db),
) -> dict:
"""Store this user's public key + passphrase-wrapped private key. Set-once: changing the
key would orphan every existing conversation key, so re-setup is rejected here."""
if db.get(MessageKey, user.id) is not None:
raise refused(409, "messaging_set_up")
db.add(
MessageKey(
user_id=user.id,
public_key=payload.public_key,
wrapped_private_key=payload.wrapped_private_key,
salt=payload.salt,
wrap_iv=payload.wrap_iv,
key_check=payload.key_check,
)
)
db.commit()
return {"ok": True, "configured": True}
@router.post("/keys/reset")
def reset_keys(
payload: KeyResetIn,
user: User = Depends(require_human),
db: Session = Depends(get_db),
) -> dict:
"""Forgotten-passphrase escape hatch: drop this user's secure-messaging key so they can set a
NEW passphrase (the set-once guard in setup_keys otherwise makes a lost passphrase a permanent
wall). Destroys the user's entire E2EE history BY DESIGN — every conversation key is derived
from this keypair, so replacing it orphans every stored ciphertext in both directions. Those
now-undecryptable `user` messages are deleted here (a clean slate for both parties) rather than
left as permanent "can't decrypt" clutter; `system` messages (plaintext) are untouched.
An account WITH a password must re-authenticate: a hijacked session must not be able to silently
reset messaging and then read the victim's future messages under a new passphrase. A password-
less (Google-only) account has nothing to re-verify, so it relies on its live session plus the
client-side danger confirmation."""
if not _reset_key_limiter.allow(str(user.id)):
raise refused(429, "too_many_attempts")
if user.password_hash and not verify_password(
payload.current_password or "", user.password_hash
):
raise refused(403, "wrong_password")
# Delete the now-dead ciphertext first (both directions), then the wrapped keypair itself so a
# fresh setup is allowed. Order doesn't matter (no FK between them), but do it in one commit.
db.execute(
delete(Message).where(
Message.kind == "user",
or_(Message.sender_id == user.id, Message.recipient_id == user.id),
)
)
key = db.get(MessageKey, user.id)
if key is not None:
db.delete(key)
db.commit()
return {"ok": True, "configured": False}
@router.get("/keys/{user_id}")
def public_key(
user_id: int, user: User = Depends(require_human), db: Session = Depends(get_db)
) -> dict:
"""A member's public key, for deriving the pairwise conversation key. MB2 (like get_thread):
resolve the target only if they're messageable OR we already share a thread, else the same
"User not found" as a non-existent id — so this can't be probed to enumerate user_ids or to
fetch keys for suspended/deactivated/demo accounts."""
cond = and_(
Message.kind == "user",
or_(
and_(Message.sender_id == user.id, Message.recipient_id == user_id),
and_(Message.sender_id == user_id, Message.recipient_id == user.id),
),
)
p = db.get(User, user_id)
has_history = db.scalar(select(Message.id).where(cond).limit(1))
if p is None or (not is_messageable_user(p) and not has_history):
raise not_found("user")
k = db.get(MessageKey, user_id)
if k is None:
raise refused(404, "no_messaging")
return {"user_id": user_id, "public_key": k.public_key}
# --- directory + conversations -----------------------------------------------
@router.get("/users")
def list_recipients(
user: User = Depends(require_human), db: Session = Depends(get_db)
) -> list[dict]:
"""Member directory for the recipient picker: messageable members (except me) who have set
up secure messaging (so a conversation key can be derived)."""
rows = (
db.execute(
select(User)
.join(MessageKey, MessageKey.user_id == User.id)
.where(User.id != user.id, *messageable_clauses())
.order_by(collated(func.coalesce(User.display_name, User.email), lang_for_user(user)))
)
.scalars()
.all()
)
return [_serialize_user(u) for u in rows]
@router.get("/conversations")
def list_conversations(user: User = Depends(require_human), db: Session = Depends(get_db)) -> dict:
"""One entry per conversation: each user partner (E2EE) plus the system "Siftlode" thread,
with the latest message + my unread count, newest first. Lazily creates the welcome on the
first open. User-message previews are ciphertext — the client decrypts them."""
ensure_welcome(db, user)
# The conversation partner for a message, from this user's perspective (system → 0).
partner_expr = case(
(Message.kind == "system", SYSTEM_PARTNER_ID),
(Message.sender_id == user.id, Message.recipient_id),
else_=Message.sender_id,
)
mine = or_(Message.sender_id == user.id, Message.recipient_id == user.id)
# Latest message per conversation in ONE query (DISTINCT ON, Postgres) instead of loading the
# user's whole message history into Python — bounded by the number of conversations.
latest = (
db.execute(
select(Message)
.where(mine)
.distinct(partner_expr)
.order_by(partner_expr, Message.created_at.desc())
)
.scalars()
.all()
)
# Unread incoming, grouped per conversation.
unread = dict(
db.execute(
select(partner_expr, func.count())
.where(mine, Message.recipient_id == user.id, Message.read_at.is_(None))
.group_by(partner_expr)
).all()
)
def _pid(m: Message) -> int:
if m.kind == "system":
return SYSTEM_PARTNER_ID
return m.recipient_id if m.sender_id == user.id else m.sender_id
user_ids = [_pid(m) for m in latest if m.kind != "system"]
partners = {}
if user_ids:
partners = {
u.id: u for u in db.execute(select(User).where(User.id.in_(user_ids))).scalars().all()
}
items = []
for m in latest:
pid = _pid(m)
if pid == SYSTEM_PARTNER_ID:
partner = _system_partner()
elif pid in partners:
partner = _serialize_user(partners[pid])
else:
continue
items.append(
{
"partner": partner,
"last_message": _serialize_msg(m, lang_for_user(user)),
"unread": int(unread.get(pid, 0)),
}
)
items.sort(key=lambda x: x["last_message"]["created_at"] or "", reverse=True)
return {"items": items}
@router.get("/conversations/{partner_id}")
def get_thread(
partner_id: int,
mark_read: bool = True,
user: User = Depends(require_human),
db: Session = Depends(get_db),
) -> dict:
"""Full thread with a partner (or the system thread when partner_id == 0), oldest first.
Stamps unread incoming messages read unless mark_read=false."""
if partner_id == SYSTEM_PARTNER_ID:
ensure_welcome(db, user)
partner = _system_partner()
cond = and_(Message.recipient_id == user.id, Message.kind == "system")
else:
p = db.get(User, partner_id)
cond = and_(
Message.kind == "user",
or_(
and_(Message.sender_id == user.id, Message.recipient_id == partner_id),
and_(Message.sender_id == partner_id, Message.recipient_id == user.id),
),
)
# MB2: don't leak arbitrary user identities. Resolve a partner only if they're messageable
# OR we already share a thread (so a conversation with a since-suspended/deactivated user
# still opens). Otherwise the same 404 as a non-existent id → no enumeration oracle.
has_history = db.scalar(select(Message.id).where(cond).limit(1))
if p is None or (not is_messageable_user(p) and not has_history):
raise not_found("user")
partner = _serialize_user(p)
msgs = (
db.execute(select(Message).where(cond).order_by(Message.created_at.asc())).scalars().all()
)
if mark_read:
now = datetime.now(timezone.utc)
changed = False
for m in msgs:
if m.recipient_id == user.id and m.read_at is None:
m.read_at = now
changed = True
if changed:
db.commit()
# MB3: ping this user's OTHER tabs/devices that their unread dropped so the badge
# refreshes live instead of waiting for the next 20s poll. The client re-fetches the
# count on this signal, so no number is carried here.
manager.push(user.id, {"type": "unread"})
return {"partner": partner, "items": [_serialize_msg(m, lang_for_user(user)) for m in msgs]}
@router.post("")
def send_message(
payload: SendIn,
user: User = Depends(require_human),
db: Session = Depends(get_db),
) -> dict:
"""Send an end-to-end-encrypted message. The server stores only the ciphertext the client
produced, then pushes it live to the recipient (and the sender's other devices)."""
if not _send_limiter.allow(f"msg:{user.id}"):
raise refused(429, "message_rate")
ct = (payload.ciphertext or "").strip()
iv = (payload.iv or "").strip()
if not ct or not iv:
raise refused(400, "message_empty")
if len(ct) > MAX_CIPHERTEXT:
raise refused(400, "message_too_long")
if payload.recipient_id == user.id:
raise refused(400, "message_self")
recipient = db.get(User, payload.recipient_id)
if not is_messageable_user(recipient):
raise not_found("recipient")
if db.get(MessageKey, recipient.id) is None:
raise refused(404, "no_messaging")
m = Message(kind="user", sender_id=user.id, recipient_id=recipient.id, ciphertext=ct, iv=iv)
db.add(m)
db.commit()
db.refresh(m)
# Carry both parties so each recipient can open/flash the right dock window (the partner is
# whichever of from/to isn't them).
event = {
"type": "message",
"message": _serialize_msg(m),
"from": _serialize_user(user),
"to": _serialize_user(recipient),
}
manager.push(recipient.id, event)
manager.push(user.id, event) # the sender's other open devices
return _serialize_msg(m)
@router.get("/unread_count")
def unread_count(user: User = Depends(require_human), db: Session = Depends(get_db)) -> dict:
n = db.scalar(
select(func.count())
.select_from(Message)
.where(Message.recipient_id == user.id, Message.read_at.is_(None))
)
return {"count": n or 0}
# --- live delivery -----------------------------------------------------------
@router.websocket("/ws")
async def messages_ws(ws: WebSocket) -> None:
"""Live push channel: authenticated by the session cookie, registers the connection so
sent messages reach this user's open tabs instantly. We don't consume client→server frames
(sending goes through POST /api/messages); the receive loop only detects disconnect."""
# Which signed-in account this socket acts as — the SHARED per-tab resolution (a wallet-gated
# ?account=, the session default when none is named, nobody for an account no longer signed in
# here). A browser WebSocket can't send the X-Siftlode-Account header, so resolved_user_id falls
# through to the ?account= query param.
uid, _ = resolved_user_id(ws)
if not uid:
await ws.close(code=1008)
return
db = SessionLocal()
try:
user = db.get(User, uid)
# SA4: honour server-side session revocation on the live channel too — reject a cookie whose
# recorded epoch is behind the account's current one (mirrors current_user). Without this a
# copied cookie could keep receiving pushes after a password reset / "log out everywhere".
epochs = ws.session.get("epochs") or {}
epoch_ok = user is not None and int(epochs.get(str(uid), 0)) == (user.session_epoch or 0)
ok = epoch_ok and is_messageable_user(user)
finally:
db.close()
if not ok:
await ws.close(code=1008)
return
await manager.connect(uid, ws)
try:
while True:
await ws.receive_text()
# silent-ok: the ordinary way a chat socket ends — a closed tab. The `finally` below is the
# whole handling, and nothing failed.
except WebSocketDisconnect:
pass
finally:
await manager.disconnect(uid, ws)
+1 -1
View File
@@ -11,7 +11,7 @@ Design:
instantly (no re-download); a fresh asset is claimed pending→downloading by exactly one instantly (no re-download); a fresh asset is claimed pending→downloading by exactly one
worker (a DB compare-and-set), so two jobs for the same video+format never double-download. worker (a DB compare-and-set), so two jobs for the same video+format never double-download.
* Live progress is written to the job row (short-lived sessions, throttled ~1/s) — the * Live progress is written to the job row (short-lived sessions, throttled ~1/s) — the
frontend polls it; the worker can't reach the API's in-process WebSocket registry. frontend polls it (the worker is a separate process with no channel to the browser).
* Pause/cancel are cooperative: the progress hook re-reads the job status and aborts the * Pause/cancel are cooperative: the progress hook re-reads the job status and aborts the
yt-dlp download if it's no longer `running`. yt-dlp download if it's no longer `running`.
* Crash-safe: on startup, orphaned `running` jobs are requeued and `downloading` assets reset. * Crash-safe: on startup, orphaned `running` jobs are requeued and `downloading` assets reset.
+1 -1
View File
@@ -31,7 +31,7 @@
} }
}, },
{ {
"name": "E2EE material and cookies", "name": "ciphertext, passphrases and cookies",
"key": "", "key": "",
"value": { "value": {
"ciphertext": "AAAA", "ciphertext": "AAAA",
+2 -19
View File
@@ -221,30 +221,13 @@ def _others(db, n_offset: int = 0) -> list[User]:
return users return users
def test_the_share_and_message_recipients_are_in_the_readers_alphabet( def test_the_share_recipients_are_in_the_readers_alphabet(db, monkeypatch, override_dep):
db, monkeypatch, override_dep
):
from app.models import MessageKey
user = _user(db, "hu") user = _user(db, "hu")
others = _others(db) _others(db)
db.add_all(
MessageKey(
user_id=u.id,
public_key="k",
wrapped_private_key="w",
salt="s",
wrap_iv="i",
key_check="c",
)
for u in others
)
db.commit() db.commit()
client = _client(db, user, monkeypatch, override_dep) client = _client(db, user, monkeypatch, override_dep)
emails = [r["email"] for r in client.get("/api/downloads/recipients").json()] emails = [r["email"] for r in client.get("/api/downloads/recipients").json()]
assert emails == [f"{w.lower()}@example.com" for w in HU] assert emails == [f"{w.lower()}@example.com" for w in HU]
names = [r["name"] for r in client.get("/api/messages/users").json()]
assert names == HU
def test_the_blocked_channels_are_in_the_readers_alphabet(db, monkeypatch, override_dep): def test_the_blocked_channels_are_in_the_readers_alphabet(db, monkeypatch, override_dep):
+4 -4
View File
@@ -219,11 +219,11 @@ def _client() -> TestClient:
raise HTTPException(status_code=400, detail={"code": "bad_cursor"}) raise HTTPException(status_code=400, detail={"code": "bad_cursor"})
return {"ok": True} return {"ok": True}
@app.get("/api/messages/unread_count") @app.get("/api/me/notifications/unread_count")
def _unread(): def _unread():
return {"count": 0} return {"count": 0}
@app.post("/api/messages/unread_count") @app.post("/api/me/notifications/unread_count")
def _unread_write(): def _unread_write():
return {"ok": True} return {"ok": True}
@@ -337,11 +337,11 @@ def test_a_polled_read_is_debug_a_write_to_the_same_route_is_not(sink):
http = logging.getLogger("siftlode.http") http = logging.getLogger("siftlode.http")
http.setLevel(logs.TRACE) http.setLevel(logs.TRACE)
client = _client() client = _client()
r = client.get("/api/messages/unread_count") r = client.get("/api/me/notifications/unread_count")
assert sink.objects()[-1]["level"] == "DEBUG" assert sink.objects()[-1]["level"] == "DEBUG"
# ...and says so, for the browser's copy of the line (frontend lib/http.ts). # ...and says so, for the browser's copy of the line (frontend lib/http.ts).
assert r.headers["X-Log-Quiet"] == "1" assert r.headers["X-Log-Quiet"] == "1"
r = client.post("/api/messages/unread_count") r = client.post("/api/me/notifications/unread_count")
assert sink.objects()[-1]["level"] == "INFO" assert sink.objects()[-1]["level"] == "INFO"
assert "X-Log-Quiet" not in r.headers assert "X-Log-Quiet" not in r.headers
assert client.get("/api/videos/abc").status_code == 200 assert client.get("/api/videos/abc").status_code == 200
-53
View File
@@ -1,53 +0,0 @@
"""E31 S1: the welcome message is rendered once, when the account is created, in the language of that day.
An account that switched language afterwards kept reading it in the old one (the owner's: English under a
Hungarian UI). It is served in the reader's language now; any other system message stays as stored.
Needs the DB."""
from fastapi.testclient import TestClient
import app.state as state
from app.auth import current_user
from app.db import get_db
from app.main import app
from app.models import Message, User
from app.routes.messages import WELCOME
def _client(db, user, monkeypatch, override_dep) -> TestClient:
monkeypatch.setattr(state, "_configured_cache", True)
override_dep(get_db, db.info["request"])
override_dep(current_user, lambda: user)
return TestClient(app)
def _user_with_english_welcome(db) -> User:
"""An account whose welcome was written in English, then switched to Hungarian."""
user = User(email="welcome-hu@example.com", preferences={"language": "hu"})
db.add(user)
db.flush()
db.add(Message(kind="system", sender_id=None, recipient_id=user.id, body=WELCOME["en"]))
db.commit()
return user
def test_the_welcome_is_read_in_the_readers_language(db, monkeypatch, override_dep):
user = _user_with_english_welcome(db)
client = _client(db, user, monkeypatch, override_dep)
thread = client.get("/api/messages/conversations/0").json()["items"]
assert [m["body"] for m in thread] == [WELCOME["hu"]]
convs = client.get("/api/messages/conversations").json()["items"]
assert [c["last_message"]["body"] for c in convs if c["partner"]["id"] == 0] == [WELCOME["hu"]]
def test_another_system_message_is_served_as_stored(db, monkeypatch, override_dep):
user = _user_with_english_welcome(db)
db.add(
Message(kind="system", sender_id=None, recipient_id=user.id, body="Maintenance tonight.")
)
db.commit()
client = _client(db, user, monkeypatch, override_dep)
bodies = [m["body"] for m in client.get("/api/messages/conversations/0").json()["items"]]
assert bodies == [WELCOME["hu"], "Maintenance tonight."]
+1 -3
View File
@@ -23,11 +23,9 @@ COMMITTING_HELPERS = {
"search.py:_ingest_candidates": "stages around YouTube calls: the stubs must survive an enrich " "search.py:_ingest_candidates": "stages around YouTube calls: the stubs must survive an enrich "
"that fails", "that fails",
"search.py:_classify_shorts": "the last stage of that ingest, after its probes", "search.py:_classify_shorts": "the last stage of that ingest, after its probes",
"auth.py:upsert_pending_invite": "commits only when it writes — the contract its three " "auth.py:upsert_pending_invite": "commits only when it writes — the contract its two "
"callers rely on (`_register_account` persists its own flushed user because a no-op here " "callers rely on (`_register_account` persists its own flushed user because a no-op here "
"commits nothing)", "commits nothing)",
"messages.py:ensure_welcome": "the websocket push after it must announce a row the client "
"can already fetch",
"debug_sessions.py:_end_send": "gives back the claim a send took, in a `finally`: it runs when " "debug_sessions.py:_end_send": "gives back the claim a send took, in a `finally`: it runs when "
"the handler is already unwinding and has no commit left to reach", "the handler is already unwinding and has no commit left to reach",
} }
-1
View File
@@ -55,7 +55,6 @@ them itself (`no-undef`, `no-redeclare`, `no-dupe-keys`, …), nor the formattin
| e2e | `@typescript-eslint/no-floating-promises` — narrowed | The same `allowForKnownSafeCalls` list (the typed rules are one object for both scopes). | 6 names | | e2e | `@typescript-eslint/no-floating-promises` — narrowed | The same `allowForKnownSafeCalls` list (the typed rules are one object for both scopes). | 6 names |
| lib/http.ts | `no-restricted-globals` — narrowed | A network owner (`NETWORK_OWNERS` in eslint.config.js): the one module that may call `fetch` (`send`, which `req` and the public pages go through). Every other network global stays restricted there. | 1 global | | lib/http.ts | `no-restricted-globals` — narrowed | A network owner (`NETWORK_OWNERS` in eslint.config.js): the one module that may call `fetch` (`send`, which `req` and the public pages go through). Every other network global stays restricted there. | 1 global |
| lib/api/core.ts | `no-restricted-globals` — narrowed | A network owner: `uploadReq` is the one `XMLHttpRequest` (upload progress). | 1 global | | lib/api/core.ts | `no-restricted-globals` — narrowed | A network owner: `uploadReq` is the one `XMLHttpRequest` (upload progress). | 1 global |
| lib/messagesSocket.ts | `no-restricted-globals` — narrowed | A network owner: the chat socket is the one `WebSocket`. | 1 global |
| src tests · ui tests · lib tests | `no-restricted-globals`, `no-restricted-properties` — off | A test stands a double in for the network (`vi.stubGlobal("fetch", …)`) and may reach it directly. | — | | src tests · ui tests · lib tests | `no-restricted-globals`, `no-restricted-properties` — off | A test stands a double in for the network (`vi.stubGlobal("fetch", …)`) and may reach it directly. | — |
| src tests · ui tests · lib tests | `local/literal-a11y-text`, `local/button-primitive`, `local/input-primitive`, `local/surface-role`, `local/glyph-link` — off | A test renders throwaway markup (a raw button as a focus target, an untranslated label) that is not product UI. | — | | src tests · ui tests · lib tests | `local/literal-a11y-text`, `local/button-primitive`, `local/input-primitive`, `local/surface-role`, `local/glyph-link` — off | A test renders throwaway markup (a raw button as a focus target, an untranslated label) that is not product UI. | — |
| FocusSafeButton.tsx + AnchoredPopover.tsx | `local/focus-safe-disabled` — off | FocusSafeButton: it IS the focus-safe primitive the rule points everyone else to. | — | | FocusSafeButton.tsx + AnchoredPopover.tsx | `local/focus-safe-disabled` — off | FocusSafeButton: it IS the focus-safe primitive the rule points everyone else to. | — |
+5 -5
View File
@@ -25,13 +25,13 @@ import playwright from "eslint-plugin-playwright";
// a `-- <the invariant>` description (eslint-comments/require-description makes that mandatory). // a `-- <the invariant>` description (eslint-comments/require-description makes that mandatory).
// E22 S4: every way out of the browser goes through the one module that LOGS it — a request line // E22 S4: every way out of the browser goes through the one module that LOGS it — a request line
// with a request id (lib/http.ts), the upload XHR (lib/api/core.ts), the messages socket // with a request id (lib/http.ts), the upload XHR (lib/api/core.ts). A bare `fetch` elsewhere is a
// (lib/messagesSocket.ts). A bare `fetch` elsewhere is a request the client log never sees, which is // request the client log never sees, which is exactly how the baseline survey found four. Each owner
// exactly how the baseline survey found four. Each owner is exempt from its OWN global only. // is exempt from its OWN global only; a global with no owner (null) has no exempt file at all.
const NETWORK_OWNERS = { const NETWORK_OWNERS = {
fetch: "src/lib/http.ts", fetch: "src/lib/http.ts",
XMLHttpRequest: "src/lib/api/core.ts", XMLHttpRequest: "src/lib/api/core.ts",
WebSocket: "src/lib/messagesSocket.ts", WebSocket: null,
EventSource: null, EventSource: null,
}; };
const networkGlobals = (exempt) => const networkGlobals = (exempt) =>
@@ -495,7 +495,7 @@ const suppressionRules = {
// The two type-aware rules (E32 D3: a curated pair, not strictTypeChecked — ~2000 hits there, none // The two type-aware rules (E32 D3: a curated pair, not strictTypeChecked — ~2000 hits there, none
// real in the sample). A floating promise is a rejection nobody handles: the audit found two real // real in the sample). A floating promise is a rejection nobody handles: the audit found two real
// ones (ChatDock's device key store, VideoEditor's play()). `void` marks a deliberate fire-and-forget // ones (the chat dock's device key store, since removed, and VideoEditor's play()). `void` marks a deliberate fire-and-forget
// whose callee reports its own failure. React Query's invalidate/refetch family never rejects // whose callee reports its own failure. React Query's invalidate/refetch family never rejects
// unless asked to (`throwOnError`), so those calls are known-safe by name. // unless asked to (`throwOnError`), so those calls are known-safe by name.
const typedRules = { const typedRules = {
-2
View File
@@ -35,7 +35,6 @@ import BackToTop from "./components/ui/BackToTop";
import PageShell from "./components/ui/PageShell"; import PageShell from "./components/ui/PageShell";
import { SkipLink } from "./components/ui/SkipLink"; import { SkipLink } from "./components/ui/SkipLink";
import { pageTitleKey } from "./lib/pageMeta"; import { pageTitleKey } from "./lib/pageMeta";
import ChatDock from "./components/ChatDock";
import DebugRecorder from "./components/DebugRecorder"; import DebugRecorder from "./components/DebugRecorder";
import Toaster from "./components/ui/Toaster"; import Toaster from "./components/ui/Toaster";
import KeyboardHelp from "./components/ui/KeyboardHelp"; import KeyboardHelp from "./components/ui/KeyboardHelp";
@@ -459,7 +458,6 @@ export default function App() {
"take me to X" the same way. Their keys are bound by useGlobalKeymap above. */} "take me to X" the same way. Their keys are bound by useGlobalKeymap above. */}
<KeyboardHelp /> <KeyboardHelp />
<AppCommandPalette /> <AppCommandPalette />
{me && !me.is_demo && <ChatDock meId={me.id} />}
{/* The debug recorder (E16): its start dialog, and its pill docked under the toasts. */} {/* The debug recorder (E16): its start dialog, and its pill docked under the toasts. */}
{me && !me.is_demo && <DebugRecorder />} {me && !me.is_demo && <DebugRecorder />}
<Suspense fallback={null}> <Suspense fallback={null}>
-131
View File
@@ -1,131 +0,0 @@
import { useEffect } from "react";
import { qk } from "../lib/queryKeys";
import { useTranslation } from "react-i18next";
import { useQueryClient } from "@tanstack/react-query";
import { ChevronDown, ChevronUp, X } from "lucide-react";
import {
closeChat,
initDock,
notifyIncoming,
toggleMinimize,
useDockChats,
type DockChat,
} from "../lib/chatDock";
import { onMessage, onUnread } from "../lib/messagesSocket";
import * as e2ee from "../lib/e2ee";
import { errorAttrs, log } from "../lib/log";
import Avatar from "./ui/Avatar";
import ChatThread from "./ChatThread";
import Overlay from "./ui/Overlay";
import { IconButton } from "./ui/IconButton";
import { Pressable } from "./ui/Pressable";
import { SURFACE } from "./ui/surfaces";
// The Messenger-style floating chat dock: open conversations live here, bottom-right, across
// page navigation (state persisted per user). Always mounted (for human users) so it also owns
// the app-wide live-message subscription and restores this device's unlock state on load.
export default function ChatDock({ meId }: { meId: number }) {
const qc = useQueryClient();
const chats = useDockChats();
// Restore the per-device unlocked key and the persisted dock windows once, app-wide.
useEffect(() => {
initDock(meId);
// The device key store can be unopenable (storage disabled, some private windows): messages then
// stay locked behind the passphrase, as on a new device — said in the log, not thrown at nobody.
e2ee
.loadFromDevice(meId)
.catch((err: unknown) =>
log.warn(
"ui.e2ee_device_key_unreadable",
"the device key store could not be read",
errorAttrs(err),
),
);
}, [meId]);
// Live delivery: refresh the conversation list, unread badge, and the affected thread; and
// for an INCOMING message, open or flash that partner's dock window.
useEffect(
() =>
onMessage(({ message: m, from }) => {
qc.invalidateQueries({ queryKey: qk.conversations() });
qc.invalidateQueries({ queryKey: qk.messageUnread() });
const partnerId = m.sender_id === meId ? m.recipient_id : m.sender_id;
qc.invalidateQueries({ queryKey: qk.thread(partnerId) });
// Auto-open/flash only for messages from someone else (not my own echo) and only for
// real user conversations.
if (m.kind === "user" && m.sender_id !== meId && from) {
notifyIncoming({ id: from.id, name: from.name, avatar_url: from.avatar_url });
}
}),
[qc, meId],
);
// Live badge sync: another tab/device read a thread → refresh this tab's unread count + list.
useEffect(
() =>
onUnread(() => {
qc.invalidateQueries({ queryKey: qk.messageUnread() });
qc.invalidateQueries({ queryKey: qk.conversations() });
}),
[qc],
);
if (chats.length === 0) return null;
return (
<Overlay>
<div className="fixed bottom-0 right-0 z-rail flex flex-row-reverse items-end gap-3 p-4 pointer-events-none">
{chats.map((c) => (
<ChatWindow key={c.partnerId} chat={c} meId={meId} />
))}
</div>
</Overlay>
);
}
function ChatWindow({ chat, meId }: { chat: DockChat; meId: number }) {
const { t } = useTranslation();
return (
<div
className={`${SURFACE.panel} pointer-events-auto relative w-80 max-w-[calc(100vw-2rem)] flex flex-col overflow-hidden`}
>
{/* One-shot attention flash on a new message; keyed by the counter so it replays each time. */}
{chat.flash > 0 && (
<div
key={chat.flash}
className="chat-flash pointer-events-none absolute inset-0 rounded-2xl z-base"
/>
)}
<div className="flex items-center gap-2 px-3 py-2 border-b border-border bg-card/50">
<Pressable
selectable
onClick={() => toggleMinimize(chat.partnerId)}
className="flex items-center gap-2 min-w-0 flex-1"
title={chat.minimized ? t("messages.expand") : t("messages.minimize")}
>
<Avatar
src={chat.avatar}
fallback={chat.name}
className="w-7 h-7 rounded-full shrink-0 text-xs"
/>
<span className="font-semibold text-sm truncate">{chat.name}</span>
</Pressable>
<IconButton
onClick={() => toggleMinimize(chat.partnerId)}
label={chat.minimized ? t("messages.expand") : t("messages.minimize")}
>
{chat.minimized ? <ChevronUp /> : <ChevronDown />}
</IconButton>
<IconButton onClick={() => closeChat(chat.partnerId)} label={t("messages.close")}>
<X />
</IconButton>
</div>
{!chat.minimized && (
<div className="flex flex-col h-96">
<ChatThread meId={meId} partnerId={chat.partnerId} isSystem={false} />
</div>
)}
</div>
);
}
-243
View File
@@ -1,243 +0,0 @@
import { useEffect, useEffectEvent, useRef, useState } from "react";
import { qk } from "../lib/queryKeys";
import { useTranslation } from "react-i18next";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Send } from "lucide-react";
import { api, type Message, type MessageUser } from "../lib/api";
import { useLiveQuery } from "../lib/useLiveQuery";
import { useScrollFade } from "../lib/useScrollFade";
import { IconButton } from "./ui/IconButton";
import { LoadingState, StateMessage } from "./ui/QueryState";
import { notifyActionError } from "../lib/actionErrors";
import { partnerPub, POLL_MS, refreshPartnerPub, useKeyState } from "../lib/messaging";
import * as e2ee from "../lib/e2ee";
import KeyGate from "./KeyGate";
import { Textarea } from "./ui/Input";
import { RelativeTime } from "./ui/RelativeTime";
// The message list + composer for one conversation, shared by the full Messages page and the
// floating dock window. Handles client-side decryption and encrypt-on-send; the surrounding
// chrome (back button / minimize / close) is the caller's. Self-contained key gating: prompts
// setup/unlock (server-truth) for a user conversation until messaging is ready.
export default function ChatThread({
meId,
partnerId,
isSystem,
}: {
meId: number;
partnerId: number;
isSystem: boolean;
}) {
const { t } = useTranslation();
const qc = useQueryClient();
const [draft, setDraft] = useState("");
const [plain, setPlain] = useState<Record<number, string>>({});
const bottomRef = useRef<HTMLDivElement>(null);
const { ref: fadeRef, style: fadeStyle } = useScrollFade();
const ready = useKeyState() === "ready";
const needsKey = !isSystem && !ready;
const q = useLiveQuery<{ partner: MessageUser; items: Message[] }>(
qk.thread(partnerId),
() => api.thread(partnerId),
{ intervalMs: POLL_MS, enabled: !needsKey },
);
const items = q.data?.items ?? [];
// The two effects below take the FETCH as their trigger and read the payload through this effect
// event, so their dep lists name everything they touch without making the identity of `q.data` a
// trigger.
const latestThread = useEffectEvent(() => q.data);
// Decrypt the thread when fresh server data arrives. Keyed on dataUpdatedAt (not `items`): the
// `?? []` fallback is a new array reference every render, which would otherwise re-run this
// effect (and setPlain a new object) on every render while data is loading — a render storm.
useEffect(() => {
if (isSystem || !ready) return;
let cancelled = false;
const thread = latestThread();
// Every await inside is caught per message (a failure reads "can't decrypt"), so this never rejects.
void (async () => {
const out: Record<number, string> = {};
// Fetch the partner key lazily inside the try (only when there's a ciphertext message) and
// once — so an empty thread never fetches, and a fetch failure (e.g. a partner who reset and
// hasn't re-set-up, so has no key) degrades to "can't decrypt" instead of rejecting the pass.
let pub: string | null = null;
let refreshed = false;
for (const m of thread?.items ?? []) {
if (!m.ciphertext || !m.iv) continue;
try {
if (!pub) pub = await partnerPub(partnerId);
out[m.id] = await e2ee.decryptFrom(partnerId, pub, m.ciphertext, m.iv);
} catch {
// A decrypt failure can mean the partner rotated their keypair (a passphrase reset), so
// this device's cached public key is stale. Refresh it ONCE per pass and retry — that
// also re-derives future sends under the current key. A genuinely bad message still ends
// up "can't decrypt".
if (!refreshed) {
refreshed = true;
try {
pub = await refreshPartnerPub(partnerId);
out[m.id] = await e2ee.decryptFrom(partnerId, pub, m.ciphertext, m.iv);
continue;
} catch {
/* fall through to the error label below */
}
}
out[m.id] = "⚠ " + t("messages.cantDecrypt");
}
}
if (!cancelled) setPlain(out);
})();
return () => {
cancelled = true;
};
// `dataUpdatedAt` is the trigger and the payload is read through the effect event above — see
// Messages, where keying this family on `q.data` itself produced a render loop.
}, [q.dataUpdatedAt, ready, isSystem, partnerId, t]);
// Opening the thread (or a new incoming message arriving) marks messages read server-side, so
// refresh the badges — but ONLY when the incoming-message count actually changed, not on every
// 20s poll (which would needlessly refetch /conversations + /unread_count each tick).
const lastIncoming = useRef(-1);
const lastPartner = useRef(partnerId);
useEffect(() => {
if (!q.dataUpdatedAt) return;
// The Messages page reuses one ChatThread instance across conversation switches (not keyed by
// partnerId), so reset the counter when the partner changes — otherwise switching to a thread
// with the same incoming-count would skip the open-marks-read refresh.
if (lastPartner.current !== partnerId) {
lastPartner.current = partnerId;
lastIncoming.current = -1;
}
const incoming = (latestThread()?.items ?? []).filter((m) => m.recipient_id === meId).length;
if (incoming !== lastIncoming.current) {
lastIncoming.current = incoming;
qc.invalidateQueries({ queryKey: qk.conversations() });
qc.invalidateQueries({ queryKey: qk.messageUnread() });
}
}, [q.dataUpdatedAt, partnerId, meId, qc]);
// Scroll to the newest message when the count changes (open / new message) — NOT on `plain`,
// which gets a fresh object on every poll's decrypt pass and would yank a scrolled-up reader
// back to the bottom even when nothing new arrived.
useEffect(() => {
bottomRef.current?.scrollIntoView({ block: "end" });
}, [items.length]);
const send = useMutation({
mutationFn: async (text: string) => {
const pub = await partnerPub(partnerId);
const { ciphertext, iv } = await e2ee.encryptFor(partnerId, pub, text);
return api.sendMessage(partnerId, ciphertext, iv);
},
onSuccess: () => {
setDraft("");
qc.invalidateQueries({ queryKey: qk.thread(partnerId) });
qc.invalidateQueries({ queryKey: qk.conversations() });
},
onError: (e) => {
// The draft is kept (only cleared on success) so the user can retry. 400/409/422/500 already
// raised the global error dialog; 404 (recipient gone) and 429 (rate-limited) are "caller-
// handled" in api.req (no modal), so surface those here or the send fails silently.
//
// Through the shared helper, which knows both of those rules: it no-ops on anything already
// reported, and it prefers the server's reason only when that reason is a STRUCTURED detail.
// The hand-written copy that used to live here did `e.detail || …`, so a Hungarian reader got
// the route's English sentence — "That member hasn't set up messaging yet." — which is
// exactly what round 5 changed `notifyActionError` to stop doing. A second copy of a contract
// is how the fix misses a caller (round 6).
notifyActionError(e, t("messages.sendFailed"));
},
});
const submit = () => {
const text = draft.trim();
if (text && !send.isPending) send.mutate(text);
};
if (needsKey) {
return (
<div className="flex-1 grid place-items-center p-3 overflow-y-auto">
<div className="w-full max-w-sm">
<KeyGate meId={meId} compact />
</div>
</div>
);
}
return (
<>
<div
ref={fadeRef}
style={fadeStyle}
className="flex-1 overflow-y-auto no-scrollbar p-3 flex flex-col gap-2"
>
{q.isError && !q.data ? (
<div className="m-auto">
<StateMessage tone="error" onRetry={() => q.refetch()}>
{t("common.loadError")}
</StateMessage>
</div>
) : q.isPending ? (
<div className="m-auto">
<LoadingState />
</div>
) : items.length === 0 ? (
<div className="m-auto text-sm text-muted">{t("messages.threadEmpty")}</div>
) : (
items.map((m) => {
const mine = m.sender_id === meId;
const text = m.kind === "system" ? (m.body ?? "") : (plain[m.id] ?? "…");
return (
<div key={m.id} className={`flex ${mine ? "justify-end" : "justify-start"}`}>
<div
className={`max-w-[80%] rounded-2xl px-3 py-2 text-sm whitespace-pre-wrap wrap-break-word ${
mine ? "bg-accent text-accent-fg rounded-br-sm" : "bg-card rounded-bl-sm"
}`}
>
{text}
<RelativeTime
at={m.created_at}
className={`block text-3xs mt-1 ${mine ? "text-accent-fg/70" : "text-muted"}`}
/>
</div>
</div>
);
})
)}
<div ref={bottomRef} />
</div>
{isSystem ? (
<div className="p-2 border-t border-border text-center text-xs text-muted">
{t("messages.systemReadOnly")}
</div>
) : (
<div className="flex items-end gap-2 p-2 border-t border-border">
<Textarea
className="flex-1 resize-none max-h-28"
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
submit();
}
}}
rows={1}
placeholder={t("messages.writePlaceholder")}
/>
<IconButton
label={t("messages.send")}
size="md"
variant="primary"
onClick={submit}
disabled={!draft.trim() || send.isPending}
>
<Send className="w-4 h-4" />
</IconButton>
</div>
)}
</>
);
}
-185
View File
@@ -1,185 +0,0 @@
import { useState } from "react";
import { qk } from "../lib/queryKeys";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { AlertTriangle, Lock, ShieldCheck } from "lucide-react";
import { api, HttpError } from "../lib/api";
import * as e2ee from "../lib/e2ee";
import { Button } from "./ui/Button";
import { Input } from "./ui/Input";
import { SURFACE } from "./ui/surfaces";
// Secure-messaging key gate: first-run setup (choose a passphrase) or unlock on this device.
// Self-contained — on success it updates the shared e2ee unlock state, so any observing view
// (the Messages page, a dock window) swaps out of the gate automatically. In unlock mode it also
// offers a forgotten-passphrase reset (the only escape from the set-once wall).
export default function KeyGate({ meId, compact = false }: { meId: number; compact?: boolean }) {
const { t } = useTranslation();
const qc = useQueryClient();
const keyQ = useQuery({
queryKey: qk.messageKey(),
queryFn: api.messageMyKey,
staleTime: 60_000,
});
const configured = keyQ.data?.configured ?? false;
// Password accounts must re-verify to reset (guards a hijacked session); Google-only accounts
// have no password to check. Passive observer of the qk.me() query BootGate fetches (enabled:false),
// so hasPassword follows the cache when a refetch changes it.
const meQ = useQuery({ queryKey: qk.me(), queryFn: api.me, enabled: false });
const hasPassword = meQ.data?.has_password ?? false;
const [view, setView] = useState<"gate" | "reset">("gate");
const [pass, setPass] = useState("");
const [confirm, setConfirm] = useState("");
const [curPass, setCurPass] = useState("");
const [err, setErr] = useState<string | null>(null);
// Keys the error: failing the same way twice is a new node, so a screen reader announces it again (a clear
// and a set in one handler commit together, leaving the node untouched).
const [attempt, setAttempt] = useState(0);
const [busy, setBusy] = useState(false);
async function submit() {
setAttempt((n) => n + 1);
setErr(null);
if (pass.length < 6) return setErr(t("messages.passTooShort"));
if (!configured && pass !== confirm) return setErr(t("messages.passMismatch"));
setBusy(true);
try {
if (configured) {
await e2ee.unlock(meId, pass, keyQ.data!);
} else {
const payload = await e2ee.setup(meId, pass);
await api.setupMessageKey(payload);
}
qc.invalidateQueries({ queryKey: qk.messageKey() });
} catch {
setErr(configured ? t("messages.wrongPass") : t("messages.setupFailed"));
}
setBusy(false);
}
function toReset() {
setErr(null);
setPass("");
setConfirm("");
setView("reset");
}
function cancelReset() {
setErr(null);
setCurPass("");
setView("gate");
}
async function doReset() {
setAttempt((n) => n + 1);
setErr(null);
if (hasPassword && !curPass) return setErr(t("messages.resetNeedPass"));
setBusy(true);
const confirmPass = hasPassword ? curPass : undefined;
try {
await api.resetMessageKey(confirmPass);
// Server key + dead history are gone; wipe this device's identity too so the gate re-appears
// in setup mode. Invalidate last so the refetch sees configured:false.
await e2ee.resetLocal(meId);
setCurPass("");
setView("gate");
qc.invalidateQueries({ queryKey: qk.messageKey() });
} catch (e) {
setErr(e instanceof HttpError && e.detail ? e.detail : t("messages.resetFailed"));
}
setBusy(false);
}
if (keyQ.isLoading)
return <div className="p-6 text-center text-muted text-sm">{t("common.loading")}</div>;
if (view === "reset")
return (
<div className={`${SURFACE.panel} space-y-3 ${compact ? "p-3" : "p-4"}`}>
<div className="flex items-center gap-2">
<AlertTriangle className="w-5 h-5 text-danger shrink-0" />
<div className="font-semibold text-sm">{t("messages.resetTitle")}</div>
</div>
<p className="text-sm text-muted">{t("messages.resetWarn")}</p>
{hasPassword && (
<Input
className="w-full"
type="password"
autoComplete="current-password"
value={curPass}
onChange={(e) => setCurPass(e.target.value)}
placeholder={t("messages.resetCurrentPass")}
/>
)}
{err && (
<div key={attempt} role="alert" className="text-sm text-danger">
{err}
</div>
)}
<div className="flex gap-2">
<Button variant="outline" onClick={cancelReset} disabled={busy}>
{t("common.cancel")}
</Button>
<Button
variant="danger"
onClick={doReset}
disabled={busy}
icon={<AlertTriangle className="w-4 h-4" />}
>
{t("messages.resetButton")}
</Button>
</div>
</div>
);
return (
<div className={`${SURFACE.panel} space-y-3 ${compact ? "p-3" : "p-4"}`}>
<div className="flex items-center gap-2">
<ShieldCheck className="w-5 h-5 text-accent shrink-0" />
<div className="font-semibold text-sm">
{configured ? t("messages.unlockTitle") : t("messages.setupTitle")}
</div>
</div>
{!compact && (
<p className="text-sm text-muted">
{configured ? t("messages.unlockBody") : t("messages.setupBody")}
</p>
)}
<Input
className="w-full"
type="password"
value={pass}
onChange={(e) => setPass(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && configured && submit()}
placeholder={t("messages.passphrase")}
/>
{!configured && (
<Input
className="w-full"
type="password"
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
placeholder={t("messages.passphraseConfirm")}
/>
)}
{err && (
<div key={attempt} role="alert" className="text-sm text-danger">
{err}
</div>
)}
{/* One row with a gap: as two bare inline buttons the link sat flush against the primary. */}
<div className="flex flex-wrap items-center gap-3">
<Button variant="primary" size="md" onClick={submit} disabled={busy || !pass}>
<Lock className="w-4 h-4" />
{configured ? t("messages.unlock") : t("messages.setUp")}
</Button>
{configured && (
<Button variant="link-muted" onClick={toReset} className="text-xs">
{t("messages.forgotPass")}
</Button>
)}
</div>
{!configured && !compact && <p className="text-xs text-muted">{t("messages.setupWarn")}</p>}
</div>
);
}
@@ -1,70 +0,0 @@
// @vitest-environment jsdom
//
// The conversation list as a keyboard list (E11 S5): `j`/`k` walk the conversations and Enter opens
// the one the row's name button opens — not the pop-out button beside it. The live query, the key
// state and the thread are stubbed: the list itself is what is under test.
import { afterEach, describe, expect, it, vi } from "vitest";
import { act, cleanup, render, screen } from "@testing-library/react";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return {
...actual,
useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }),
};
});
const conversation = (id: number, name: string) => ({
partner: { id, name, avatar_url: null },
last_message: { kind: "system", body: `hello from ${name}`, created_at: "2026-09-13T00:00:00Z" },
unread: 0,
});
const items = [conversation(0, "Siftlode"), conversation(7, "Anna"), conversation(9, "Bence")];
vi.mock("../lib/useLiveQuery", () => ({
useLiveQuery: () => ({ data: { items }, isError: false, isLoading: false, refetch: () => {} }),
}));
vi.mock("../lib/messaging", () => ({
SYSTEM_ID: 0,
POLL_MS: 20000,
partnerPub: async () => "",
useKeyState: () => "ready",
}));
const openChat = vi.fn();
vi.mock("../lib/chatDock", () => ({ openChat: (...a: unknown[]) => openChat(...a) }));
vi.mock("./ChatThread", () => ({
default: ({ partnerId }: { partnerId: number }) => <div data-testid="thread">{partnerId}</div>,
}));
import Messages from "./Messages";
afterEach(() => {
cleanup();
history.replaceState(null, "");
});
function press(key: string) {
act(() => {
(document.activeElement ?? document.body).dispatchEvent(
new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }),
);
});
}
describe("Messages · the conversation list is a keyboard list", () => {
it("walks the conversations with j/k and opens the focused one on Enter", () => {
render(<Messages meId={1} />);
const focusedRow = () => document.activeElement?.getAttribute("data-nav-index");
press("j");
press("j");
press("j");
expect(focusedRow()).toBe("2");
expect(document.activeElement?.textContent).toContain("Bence");
press("k");
expect(focusedRow()).toBe("1");
expect(document.activeElement?.textContent).toContain("Anna");
press("Enter");
expect(screen.getByTestId("thread").textContent).toBe("7");
expect(openChat).not.toHaveBeenCalled();
});
});
-338
View File
@@ -1,338 +0,0 @@
import { useEffect, useEffectEvent, useState } from "react";
import { qk } from "../lib/queryKeys";
import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query";
import { ArrowLeft, ShieldCheck, SquarePen, ExternalLink } from "lucide-react";
import { api, type Conversation, type MessageUser } from "../lib/api";
import { useLiveQuery } from "../lib/useLiveQuery";
import { Button } from "./ui/Button";
import { IconButton } from "./ui/IconButton";
import { LoadingState, StateMessage } from "./ui/QueryState";
import { partnerPub, POLL_MS, SYSTEM_ID, useKeyState } from "../lib/messaging";
import { useHistorySubview } from "../lib/history";
import { openChat } from "../lib/chatDock";
import * as e2ee from "../lib/e2ee";
import Avatar from "./ui/Avatar";
import KeyGate from "./KeyGate";
import ChatThread from "./ChatThread";
import NavGrid from "./ui/NavGrid";
import { navItemAttrs } from "../lib/listNav";
import { Pressable } from "./ui/Pressable";
import { Input } from "./ui/Input";
import { SURFACE } from "./ui/surfaces";
import { SectionLabel } from "./ui/Headings";
import { RelativeTime } from "./ui/RelativeTime";
type View =
| "list"
| "new"
| { partnerId: number; name?: string; avatar?: string | null; system?: boolean };
export default function Messages({ meId }: { meId: number }) {
// Sub-views live in browser history, so Back returns to the conversation list before leaving
// the module (open() pushes an entry; back() = history.back()).
const { view, open, back } = useHistorySubview<View>("list");
// Guard against a stale sub-view whose partnerId is the current user's own id (you can't
// message yourself). This can be inherited across an account switch — fall back to the list.
if (typeof view === "object" && view.partnerId !== meId) {
return (
<PageThread
meId={meId}
partnerId={view.partnerId}
isSystem={!!view.system}
seedName={view.name}
seedAvatar={view.avatar}
onBack={back}
/>
);
}
if (view === "new") {
return (
<Directory
onPick={(u) => open({ partnerId: u.id, name: u.name, avatar: u.avatar_url })}
onBack={back}
/>
);
}
return (
<ConversationList
meId={meId}
onOpen={(c) =>
open({
partnerId: c.partner.id,
name: c.partner.name,
avatar: c.partner.avatar_url,
system: c.partner.id === SYSTEM_ID,
})
}
onNew={() => open("new")}
/>
);
}
function ConversationList({
meId,
onOpen,
onNew,
}: {
meId: number;
onOpen: (c: Conversation) => void;
onNew: () => void;
}) {
const { t } = useTranslation();
const keyState = useKeyState();
const ready = keyState === "ready";
const q = useLiveQuery<{ items: Conversation[] }>(qk.conversations(), api.conversations, {
intervalMs: POLL_MS,
});
const items = q.data?.items ?? [];
const [previews, setPreviews] = useState<Record<number, string>>({});
// Decrypt conversation previews when fresh data arrives. The TRIGGER is `dataUpdatedAt` and the
// payload is read through an effect event, which is what makes the dep list below complete without
// making the identity of `q.data` a trigger. That distinction is load-bearing: the `?? []` fallback is a
// new array every render, and anything handing back a fresh object per render (a stub, a caller
// that rebuilds it) turns "re-run when the data changes" into a render loop through setPreviews.
// Measured the hard way — E27 S2 keyed this on `q.data`, and Messages.keyboard.dom.test hung.
const latestConversations = useEffectEvent(() => q.data);
useEffect(() => {
if (!ready) return;
let cancelled = false;
const conversations = latestConversations();
// Each decrypt is caught (a failure previews as "⚠"), so this never rejects.
void (async () => {
const out: Record<number, string> = {};
for (const c of conversations?.items ?? []) {
const m = c.last_message;
if (m.kind === "user" && m.ciphertext && m.iv) {
try {
const pub = await partnerPub(c.partner.id);
out[c.partner.id] = await e2ee.decryptFrom(c.partner.id, pub, m.ciphertext, m.iv);
} catch {
out[c.partner.id] = "⚠";
}
}
}
if (!cancelled) setPreviews(out);
})();
return () => {
cancelled = true;
};
}, [q.dataUpdatedAt, ready]);
function previewText(c: Conversation): string {
const m = c.last_message;
if (m.kind === "system") return m.body ?? "";
if (!ready) return "🔒 " + t("messages.encrypted");
return previews[c.partner.id] ?? "…";
}
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<SectionLabel>{t("messages.conversations")}</SectionLabel>
{ready && (
<Button variant="primary" size="xs" onClick={onNew}>
<SquarePen className="w-4 h-4" />
{t("messages.new")}
</Button>
)}
</div>
{(keyState === "setup" || keyState === "unlock") && <KeyGate meId={meId} />}
{q.isError && !q.data ? (
<StateMessage tone="error" onRetry={() => q.refetch()}>
{t("common.loadError")}
</StateMessage>
) : q.isLoading && !q.data ? (
<LoadingState shape="list" />
) : items.length === 0 ? (
<div className={`${SURFACE.panel} p-10 text-center text-muted`}>{t("messages.empty")}</div>
) : (
// A keyboard list (E11): `j`/`k` walk the conversations, Enter opens the one the name button
// opens; the pop-out button beside it keeps its own Enter.
<NavGrid className="flex flex-col gap-1.5">
{items.map((c, i) => {
const isSystem = c.partner.id === SYSTEM_ID;
return (
<div
key={c.partner.id}
{...navItemAttrs(i, c.partner.name)}
className="group flex items-center gap-1 rounded-xl state-layer transition"
>
<Pressable
selectable
data-nav-open=""
onClick={() => onOpen(c)}
className="flex items-center gap-3 p-2.5 min-w-0 flex-1"
>
<Avatar
src={c.partner.avatar_url}
fallback={c.partner.name}
className="w-10 h-10 rounded-full shrink-0 text-sm"
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span
className={`truncate ${c.unread > 0 ? "font-semibold" : "font-medium"}`}
>
{c.partner.name}
</span>
<RelativeTime
at={c.last_message.created_at}
className="ml-auto text-2xs text-muted shrink-0"
/>
</div>
<div className="flex items-center gap-2">
<span
className={`truncate text-sm ${c.unread > 0 ? "text-fg" : "text-muted"}`}
>
{previewText(c)}
</span>
{c.unread > 0 && (
<span className="ml-auto shrink-0 min-w-5 h-5 px-1.5 grid place-items-center rounded-full bg-accent text-accent-fg text-2xs font-semibold">
{c.unread}
</span>
)}
</div>
</div>
</Pressable>
{!isSystem && ready && (
<IconButton
size="md"
onClick={() => openChat(c.partner)}
label={t("messages.popOut")}
className="mr-1.5 reveal"
>
<ExternalLink />
</IconButton>
)}
</div>
);
})}
</NavGrid>
)}
</div>
);
}
function Directory({ onPick, onBack }: { onPick: (u: MessageUser) => void; onBack: () => void }) {
const { t } = useTranslation();
const [filter, setFilter] = useState("");
const q = useQuery({ queryKey: qk.messageUsers(), queryFn: api.messageUsers });
const users = (q.data ?? []).filter((u) =>
u.name.toLowerCase().includes(filter.trim().toLowerCase()),
);
return (
<div className="space-y-3">
<div className="flex items-center gap-2">
<IconButton
label={t("channel.back")}
size="sm"
variant="ghost"
// An icon-only button needs a name: both of these announced themselves as a bare "button",
// and they are the only way out of the sub-view (E10 review round 1).
onClick={onBack}
>
<ArrowLeft className="w-4 h-4" />
</IconButton>
<SectionLabel>{t("messages.newTitle")}</SectionLabel>
</div>
<Input
className="w-full"
value={filter}
onChange={(e) => setFilter(e.target.value)}
placeholder={t("messages.searchPeople")}
/>
{q.isError && !q.data ? (
<StateMessage tone="error" onRetry={() => q.refetch()}>
{t("common.loadError")}
</StateMessage>
) : q.isLoading ? (
<LoadingState shape="list" />
) : users.length === 0 ? (
<div className={`${SURFACE.panel} p-10 text-center text-muted`}>
{t("messages.noPeople")}
</div>
) : (
<div className="flex flex-col gap-1">
{users.map((u) => (
<Pressable
selectable
key={u.id}
layer
onClick={() => onPick(u)}
className="flex items-center gap-3 p-2.5 rounded-xl"
>
<Avatar
src={u.avatar_url}
fallback={u.name}
className="w-9 h-9 rounded-full shrink-0 text-sm"
/>
<span className="truncate font-medium">{u.name}</span>
</Pressable>
))}
</div>
)}
</div>
);
}
function PageThread({
meId,
partnerId,
isSystem,
seedName,
seedAvatar,
onBack,
}: {
meId: number;
partnerId: number;
isSystem: boolean;
seedName?: string;
seedAvatar?: string | null;
onBack: () => void;
}) {
const { t } = useTranslation();
const ready = useKeyState() === "ready";
const name = seedName ?? "";
return (
<div className="flex flex-col h-[65dvh] min-h-80">
<div className="flex items-center gap-3 pb-3 border-b border-border">
<IconButton
label={t("channel.back")}
size="sm"
variant="ghost"
// An icon-only button needs a name: both of these announced themselves as a bare "button",
// and they are the only way out of the sub-view (E10 review round 1).
onClick={onBack}
>
<ArrowLeft className="w-4 h-4" />
</IconButton>
<Avatar
src={seedAvatar}
fallback={name}
className="w-9 h-9 rounded-full shrink-0 text-sm"
/>
<span className="font-semibold truncate">{name}</span>
{!isSystem && (
<ShieldCheck
className="w-4 h-4 text-muted shrink-0"
aria-label={t("messages.encryptedHint")}
/>
)}
<div className="flex-1" />
{!isSystem && ready && (
<IconButton
size="md"
onClick={() => openChat({ id: partnerId, name, avatar_url: seedAvatar ?? null })}
label={t("messages.popOut")}
>
<ExternalLink />
</IconButton>
)}
</div>
<ChatThread meId={meId} partnerId={partnerId} isSystem={isSystem} />
</div>
);
}
+1 -18
View File
@@ -7,7 +7,6 @@ import { Info, LogOut, Settings, Shield, UserPlus, type LucideIcon } from "lucid
import { api, type Me } from "../lib/api"; import { api, type Me } from "../lib/api";
import { displayName, roleKey } from "../lib/api/account"; import { displayName, roleKey } from "../lib/api/account";
import { pinOnNextPage } from "../lib/activeAccount"; import { pinOnNextPage } from "../lib/activeAccount";
import * as e2ee from "../lib/e2ee";
import { useLiveQuery } from "../lib/useLiveQuery"; import { useLiveQuery } from "../lib/useLiveQuery";
import { getUnreadCount, subscribe } from "../lib/notifications"; import { getUnreadCount, subscribe } from "../lib/notifications";
import type { Page } from "../lib/urlState"; import type { Page } from "../lib/urlState";
@@ -84,14 +83,6 @@ export default function NavSidebar({
} catch { } catch {
/* clear locally and reload regardless */ /* clear locally and reload regardless */
} }
// Forget this device's E2EE private key on sign-out: it's otherwise persisted in IndexedDB
// and auto-unlocks on the next visit, so on a shared machine the conversations would stay
// decryptable after logout. Re-entering the message passphrase is required next time.
try {
await e2ee.clearDevice(me.id);
} catch {
/* IndexedDB unavailable (e.g. private mode) — never let it block the logout + reload */
}
pinOnNextPage(null); pinOnNextPage(null);
location.reload(); location.reload();
} }
@@ -112,8 +103,7 @@ export default function NavSidebar({
pinOnNextPage(id); pinOnNextPage(id);
// Drop the previous account's in-module sub-view/overlay before the reload (which would // Drop the previous account's in-module sub-view/overlay before the reload (which would
// otherwise preserve history.state across the swap). Otherwise the new account lands on a // otherwise preserve history.state across the swap). Otherwise the new account lands on a
// stale sub-view — e.g. an open chat thread whose partnerId belongs to the OLD identity // stale sub-view that belongs to the OLD identity. Start at the module root instead.
// (often the new account's own id → an empty self-thread). Start at the module root instead.
const st = { ...(window.history.state || {}) }; const st = { ...(window.history.state || {}) };
delete st._sub; delete st._sub;
delete st._ov; delete st._ov;
@@ -130,12 +120,6 @@ export default function NavSidebar({
// events (the former separate bell is now folded into the inbox page). // events (the former separate bell is now folded into the inbox page).
const clientUnread = useSyncExternalStore(subscribe, getUnreadCount, getUnreadCount); const clientUnread = useSyncExternalStore(subscribe, getUnreadCount, getUnreadCount);
const unread = (unreadQuery.data?.count ?? 0) + clientUnread; const unread = (unreadQuery.data?.count ?? 0) + clientUnread;
// Direct messages are their own module with their own unread badge (demo can't message).
const msgUnreadQuery = useLiveQuery(qk.messageUnread(), api.messageUnreadCount, {
intervalMs: 30000,
enabled: !me.is_demo,
});
const msgUnread = msgUnreadQuery.data?.count ?? 0;
// Download center badge = items still working (queued/running/paused). Reuses the same // Download center badge = items still working (queued/running/paused). Reuses the same
// lightweight per-user index the feed cards poll, so no extra request shape. // lightweight per-user index the feed cards poll, so no extra request shape.
const dlIndexQuery = useLiveQuery(qk.downloadIndex(), api.downloadIndex, { const dlIndexQuery = useLiveQuery(qk.downloadIndex(), api.downloadIndex, {
@@ -155,7 +139,6 @@ export default function NavSidebar({
// badge NUMBERS are the only nav-only concern (they need the live polling queries above). // badge NUMBERS are the only nav-only concern (they need the live polling queries above).
const BADGE: Partial<Record<Page, number>> = { const BADGE: Partial<Record<Page, number>> = {
notifications: unread, notifications: unread,
messages: msgUnread,
downloads: dlActive, downloads: dlActive,
}; };
const META = (p: Page): Omit<NavItem, "page"> => ({ const META = (p: Page): Omit<NavItem, "page"> => ({
+1 -1
View File
@@ -1348,7 +1348,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
const currentAudio = detail?.audio_streams.find((x) => x.ord === (audioOrd ?? 0)); const currentAudio = detail?.audio_streams.find((x) => x.ord === (audioOrd ?? 0));
const currentSub = detail?.subtitle_streams.find((x) => x.ord === subOrd); const currentSub = detail?.subtitle_streams.find((x) => x.ord === subOrd);
// Portaled to <body>, like every other full-screen overlay here (Modal, PlayerModal, ChatDock, // Portaled to <body>, like every other full-screen overlay here (Modal, PlayerModal,
// BackToTop). A fixed, full-viewport player must not be painted inside the page scroller: the // BackToTop). A fixed, full-viewport player must not be painted inside the page scroller: the
// moment that scroller gets a mask/filter/transform/contain it becomes a stacking context, and // moment that scroller gets a mask/filter/transform/contain it becomes a stacking context, and
// this z-overlay is then scoped inside it — the rail, the side panel and the header paint straight // this z-overlay is then scoped inside it — the rail, the side panel and the header paint straight
+1 -1
View File
@@ -46,7 +46,7 @@ export default function UploadDialog({ onClose }: { onClose: () => void }) {
// Every pick gets a new number, so the refusal below REMOUNTS rather than being reconciled. Two // Every pick gets a new number, so the refusal below REMOUNTS rather than being reconciled. Two
// over-cap picks in a row leave the node and its text identical — the message names the cap, not // over-cap picks in a row leave the node and its text identical — the message names the cap, not
// the file — and a live region that is not touched is not announced, so the second refusal was // the file — and a live region that is not touched is not announced, so the second refusal was
// silent to a screen reader. `KeyGate`, `SetupWizard` and `Welcome` all key their alerts for the // silent to a screen reader. `SetupWizard` and `Welcome` both key their alerts for the
// same reason, and `formAlerts.dom.test.tsx` is the test that exists because of it. // same reason, and `formAlerts.dom.test.tsx` is the test that exists because of it.
const [pickSeq, setPickSeq] = useState(0); const [pickSeq, setPickSeq] = useState(0);
const pick_ = (f: File) => { const pick_ = (f: File) => {
@@ -4,10 +4,9 @@
// clear and the set of a validation failure commit together, so the alert node used to stay untouched on // clear and the set of a validation failure commit together, so the alert node used to stay untouched on
// the second attempt and a screen reader said nothing — a press that seemed to do nothing. // the second attempt and a screen reader said nothing — a press that seemed to do nothing.
// //
// i18n is mocked to a key-passthrough; the API and the E2EE module are stubbed so nothing reaches out. // i18n is mocked to a key-passthrough; the API is stubbed so nothing reaches out.
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/react"; import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
vi.mock("react-i18next", async (importOriginal) => { vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>(); const actual = await importOriginal<typeof import("react-i18next")>();
@@ -24,16 +23,12 @@ vi.mock("../lib/api", async (importOriginal) => {
api: { api: {
...actual.api, ...actual.api,
authConfig: () => Promise.resolve({ google_enabled: false }), authConfig: () => Promise.resolve({ google_enabled: false }),
messageMyKey: () => Promise.resolve({ configured: true }),
me: () => Promise.resolve({ authenticated: false }), me: () => Promise.resolve({ authenticated: false }),
setupInfo: () => Promise.resolve({ secrets_manageable: false }), setupInfo: () => Promise.resolve({ secrets_manageable: false }),
}, },
}; };
}); });
vi.mock("../lib/e2ee", () => ({ unlock: vi.fn(), setup: vi.fn(), clearDevice: vi.fn() }));
import KeyGate from "./KeyGate";
import SetupWizard from "./SetupWizard"; import SetupWizard from "./SetupWizard";
import Welcome from "./Welcome"; import Welcome from "./Welcome";
@@ -54,19 +49,6 @@ async function failTwice(fail: () => void, message: string) {
} }
describe("a form failing the same way twice announces it twice", () => { describe("a form failing the same way twice announces it twice", () => {
it("the message passphrase gate", async () => {
render(
<QueryClientProvider client={new QueryClient()}>
<KeyGate meId={1} />
</QueryClientProvider>,
);
const unlock = await screen.findByText("messages.unlock");
fireEvent.change(screen.getByPlaceholderText("messages.passphrase"), {
target: { value: "abc" },
});
await failTwice(() => fireEvent.click(unlock), "messages.passTooShort");
});
it("the sign-in card", async () => { it("the sign-in card", async () => {
render( render(
<Welcome verify={{ outcome: null, email: null, resetPending: false, dismiss: () => {} }} />, <Welcome verify={{ outcome: null, email: null, resetPending: false, dismiss: () => {} }} />,
@@ -16,7 +16,7 @@ import LanguageSwitcher from "../LanguageSwitcher";
import { SURFACE } from "../ui/surfaces"; import { SURFACE } from "../ui/surfaces";
// The day the text last changed materially — bump it with every edit of either body. // The day the text last changed materially — bump it with every edit of either body.
const LAST_UPDATED = "2026-09-17"; const LAST_UPDATED = "2026-10-04";
// Contact shown on the legal pages: the instance operator's configured admin email, from the // Contact shown on the legal pages: the instance operator's configured admin email, from the
// public /auth/config. One shared fetch across the page; null when the operator set none. // public /auth/config. One shared fetch across the page; null when the operator set none.
@@ -87,10 +87,7 @@ export function PrivacyEn() {
that log is sent to the server and stored with your account alongside the server's more that log is sent to the server and stored with your account alongside the server's more
detailed record of your requests, for the same retention period as the server logs; detailed record of your requests, for the same retention period as the server logs;
</li> </li>
<li> <li>passwords, tokens and email addresses are never included, at any level.</li>
passwords, tokens, email addresses and the content of your messages are never included, at
any level.
</li>
</ul> </ul>
<p> <p>
After 24 hours the level returns to the default by itself, and you can return to it earlier After 24 hours the level returns to the default by itself, and you can return to it earlier
@@ -112,10 +109,9 @@ export function PrivacyEn() {
<H2 id="storage">Storage &amp; security</H2> <H2 id="storage">Storage &amp; security</H2>
<p> <p>
Data is stored in a private PostgreSQL database on a server we control, accessible only to Data is stored in a private PostgreSQL database on a server we control, accessible only to
the operator. Google OAuth refresh tokens are encrypted at rest. Private messages between the operator. Google OAuth refresh tokens are encrypted at rest. Access is restricted to the
users are end-to-end encrypted in the browser: the server stores only the encrypted text and email addresses admitted to the app. No system is perfectly secure, but we take reasonable
cannot read it. Access is restricted to the email addresses admitted to the app. No system measures to protect your data.
is perfectly secure, but we take reasonable measures to protect your data.
</p> </p>
<H2 id="retention">Retention &amp; deletion</H2> <H2 id="retention">Retention &amp; deletion</H2>
@@ -230,7 +226,7 @@ export function PrivacyHu() {
ezt a naplót elküldi a szervernek, amely a fiókodhoz tárolja, a kéréseid részletesebb ezt a naplót elküldi a szervernek, amely a fiókodhoz tárolja, a kéréseid részletesebb
szerveroldali naplójával együtt, a szervernapló megőrzési idejéig; szerveroldali naplójával együtt, a szervernapló megőrzési idejéig;
</li> </li>
<li>jelszó, token, e-mail-cím és az üzeneteid tartalma egyik szinten sem kerül bele.</li> <li>jelszó, token és e-mail-cím egyik szinten sem kerül bele.</li>
</ul> </ul>
<p> <p>
24 óra múlva a szint magától visszaáll az alapra, és bármikor korábban is visszaállíthatod. 24 óra múlva a szint magától visszaáll az alapra, és bármikor korábban is visszaállíthatod.
@@ -252,11 +248,9 @@ export function PrivacyHu() {
<H2 id="storage">Tárolás és biztonság</H2> <H2 id="storage">Tárolás és biztonság</H2>
<p> <p>
Az adatokat egy általunk kezelt szerveren, privát PostgreSQL-adatbázisban tároljuk, amelyhez Az adatokat egy általunk kezelt szerveren, privát PostgreSQL-adatbázisban tároljuk, amelyhez
csak az üzemeltető fér hozzá. A Google OAuth frissítő tokeneket titkosítva tároljuk. A csak az üzemeltető fér hozzá. A Google OAuth frissítő tokeneket titkosítva tároljuk. Csak az
felhasználók közötti privát üzenetek végponttól végpontig titkosítottak a böngészőben: a alkalmazásba beengedett e-mail-címek férnek hozzá. Egyetlen rendszer sem tökéletesen
szerver csak a titkosított szöveget tárolja, és nem tudja elolvasni. Csak az alkalmazásba biztonságos, de észszerű intézkedésekkel védjük az adataidat.
beengedett e-mail-címek férnek hozzá. Egyetlen rendszer sem tökéletesen biztonságos, de
észszerű intézkedésekkel védjük az adataidat.
</p> </p>
<H2 id="retention">Megőrzés és törlés</H2> <H2 id="retention">Megőrzés és törlés</H2>
-10
View File
@@ -10,7 +10,6 @@ import { useSavedViews } from "./savedViewsContext";
// that file exports only data and this one only components (Fast Refresh). Their lazy chunks are // that file exports only data and this one only components (Fast Refresh). Their lazy chunks are
// exported alongside so the registry can attach each chunk's preload to the wrapper that renders it. // exported alongside so the registry can attach each chunk's preload to the wrapper that renders it.
export const Feed = lazyWithPreload(() => import("./Feed")); export const Feed = lazyWithPreload(() => import("./Feed"));
export const Messages = lazyWithPreload(() => import("./Messages"));
// The GLOBAL feed page: wires <Feed> to the app-wide feed filters / view / live-search. (The channel // The GLOBAL feed page: wires <Feed> to the app-wide feed filters / view / live-search. (The channel
// page renders <Feed> itself with its own channel-scoped filters, so those two never share state.) // page renders <Feed> itself with its own channel-scoped filters, so those two never share state.)
@@ -43,12 +42,3 @@ export function FeedPage() {
</> </>
); );
} }
export function MessagesPage() {
const me = useMe();
return (
<div className="p-4 max-w-3xl w-full mx-auto">
<Messages meId={me.id} />
</div>
);
}
+1 -2
View File
@@ -4,7 +4,7 @@ import type { Page } from "../lib/urlState";
import type { PanelId, PanelLayout } from "../lib/panelLayout"; import type { PanelId, PanelLayout } from "../lib/panelLayout";
import Sidebar from "./Sidebar"; import Sidebar from "./Sidebar";
import PlaylistsRail from "./PlaylistsRail"; import PlaylistsRail from "./PlaylistsRail";
import { Feed, FeedPage, Messages, MessagesPage } from "./modulePages"; import { Feed, FeedPage } from "./modulePages";
// The RENDER half of the module registry: which component (and side rail) each page renders. The // The RENDER half of the module registry: which component (and side rail) each page renders. The
// metadata half — nav order, label, icon, gate — lives in lib/modules.ts (imported by NavSidebar / // metadata half — nav order, label, icon, gate — lives in lib/modules.ts (imported by NavSidebar /
@@ -54,7 +54,6 @@ export const PAGE_CONTENT: Record<Page, PageDef> = {
playlists: { Component: Playlists, maxWidth: "64rem" }, playlists: { Component: Playlists, maxWidth: "64rem" },
plex: { Component: PlexBrowse, maxWidth: "100rem" }, plex: { Component: PlexBrowse, maxWidth: "100rem" },
notifications: { Component: NotificationsPanel, maxWidth: "48rem" }, notifications: { Component: NotificationsPanel, maxWidth: "48rem" },
messages: { Component: Object.assign(MessagesPage, { preload: Messages.preload }) },
downloads: { Component: DownloadCenter, maxWidth: "56rem" }, downloads: { Component: DownloadCenter, maxWidth: "56rem" },
stats: { Component: Stats }, stats: { Component: Stats },
scheduler: { Component: Scheduler }, scheduler: { Component: Scheduler },
@@ -60,8 +60,8 @@ describe("Avatar · a failed image must not follow the component to the next ite
}); });
it("retries a URL it comes back to — a 429 is transient, and so is the memory of it", () => { it("retries a URL it comes back to — a 429 is transient, and so is the memory of it", () => {
// The instance is not remounted here on purpose: the player's channel avatar and the Messages // The instance is not remounted here on purpose: the player's channel avatar swaps `src` on a
// thread header both swap `src` on a live instance. A memory that outlived the reason for it // live instance as it steps through a queue. A memory that outlived the reason for it
// would pin a throttled channel to its initial for the rest of the session. // would pin a throttled channel to its initial for the rest of the session.
const { rerender } = render( const { rerender } = render(
<Avatar src="https://cdn.test/a.jpg" alt="avatar" fallback="Zed" />, <Avatar src="https://cdn.test/a.jpg" alt="avatar" fallback="Zed" />,
+1 -1
View File
@@ -29,7 +29,7 @@ export default function Avatar({
// //
// Forgetting the URL once `src` has moved off it is the other half, and it is what keeps the 429 // Forgetting the URL once `src` has moved off it is the other half, and it is what keeps the 429
// TRANSIENT: without it the memory outlives the reason for it, and a mounted instance whose `src` // TRANSIENT: without it the memory outlives the reason for it, and a mounted instance whose `src`
// swaps — the player's channel avatar stepping through a queue, the Messages thread header — would // swaps — the player's channel avatar stepping through a queue — would
// show the initial for a throttled channel forever, never re-requesting even after the CDN // show the initial for a throttled channel forever, never re-requesting even after the CDN
// relented. Written in render because it is convergent (the write falsifies its own condition) and // relented. Written in render because it is convergent (the write falsifies its own condition) and
// because an effect would leave one committed frame in which the memory is stale, which is the // because an effect would leave one committed frame in which the memory is stale, which is the
+1 -1
View File
@@ -112,7 +112,7 @@ export function NavRailItem({
}} }}
// With the label visible the row's name starts with that text (WCAG 2.5.3: the spoken name // With the label visible the row's name starts with that text (WCAG 2.5.3: the spoken name
// contains the visible one); the full name stays in the tooltip. Icon-only, the full name is the // contains the visible one); the full name stays in the tooltip. Icon-only, the full name is the
// name. The exact count rides in the name either way, so "Messages (3)" is heard and not only // name. The exact count rides in the name either way, so "Alerts (3)" is heard and not only
// seen; the drawn badge is hidden (labelled, it was read first and capped: "9+Alerts"). // seen; the drawn badge is hidden (labelled, it was read first and capped: "9+Alerts").
aria-label={`${showLabel ? shortLabel : label}${shown ? ` (${badge})` : ""}`} aria-label={`${showLabel ? shortLabel : label}${shown ? ` (${badge})` : ""}`}
aria-current={active ? "page" : undefined} aria-current={active ? "page" : undefined}
+2 -3
View File
@@ -30,9 +30,8 @@ The direction is one-way from below, too: `lib/` may not import from `components
included, not even a type (same lint file). A contract both layers need lives in `lib/`: the `Column` included, not even a type (same lint file). A contract both layers need lives in `lib/`: the `Column`
/ `FilterMap` shape and `filterRows` are `lib/tableColumns`, which `tableHeader` and `DataTable` import. / `FilterMap` shape and `filterRows` are `lib/tableColumns`, which `tableHeader` and `DataTable` import.
Counter-examples, deliberately NOT here: `PosterTile` (knows about Plex montage posters), `KeyGate` Counter-examples, deliberately NOT here: `PosterTile` (knows about Plex montage posters),
(secure-messaging key setup), `plexCards`, `downloadRow`, `channelColumns` — all shared, none `plexCards`, `downloadRow`, `channelColumns` — all shared, none domain-free.
domain-free.
## What lives here ## What lives here
-2
View File
@@ -9,7 +9,6 @@ import {
Home, Home,
ListVideo, ListVideo,
Lock, Lock,
MessageSquare,
PlayCircle, PlayCircle,
Repeat, Repeat,
Settings, Settings,
@@ -290,7 +289,6 @@ const NAV: NavDef[] = [
{ icon: ListVideo, en: "Playlists", hu: "Listák" }, { icon: ListVideo, en: "Playlists", hu: "Listák" },
{ icon: Clapperboard, en: "Plex", hu: "Plex" }, { icon: Clapperboard, en: "Plex", hu: "Plex" },
{ icon: Bell, en: "Alerts", hu: "Értesítés" }, { icon: Bell, en: "Alerts", hu: "Értesítés" },
{ icon: MessageSquare, en: "Messages", hu: "Üzenetek" },
{ icon: Download, en: "Downloads", hu: "Letöltések" }, { icon: Download, en: "Downloads", hu: "Letöltések" },
]; ];
const SYSTEM: NavDef[] = [ const SYSTEM: NavDef[] = [
-6
View File
@@ -155,14 +155,8 @@
"link_gone": "This link is no longer available", "link_gone": "This link is no longer available",
"link_required": "A video link or id is required", "link_required": "A video link or id is required",
"media_missing": "The media file isn't on disk", "media_missing": "The media file isn't on disk",
"message_empty": "The message is empty",
"message_rate": "Too many messages — slow down a moment",
"message_self": "You can't message yourself",
"message_too_long": "The message is too long",
"messaging_set_up": "Secure messaging is already set up",
"name_required": "A name is required", "name_required": "A name is required",
"no_encryption_key": "Set TOKEN_ENCRYPTION_KEY to store secrets in the database", "no_encryption_key": "Set TOKEN_ENCRYPTION_KEY to store secrets in the database",
"no_messaging": "That member hasn't set up messaging yet",
"no_profile": "There's no download profile to use", "no_profile": "There's no download profile to use",
"no_subscription_id": "There's no YouTube subscription on record yet — sync your subscriptions first", "no_subscription_id": "There's no YouTube subscription on record yet — sync your subscriptions first",
"not_authenticated": "You're not signed in", "not_authenticated": "You're not signed in",
-1
View File
@@ -27,7 +27,6 @@
"goPlaylists": "Go to Playlists", "goPlaylists": "Go to Playlists",
"goPlex": "Go to Plex", "goPlex": "Go to Plex",
"goNotifications": "Go to Notifications", "goNotifications": "Go to Notifications",
"goMessages": "Go to Messages",
"goDownloads": "Go to Downloads", "goDownloads": "Go to Downloads",
"goStats": "Go to Stats", "goStats": "Go to Stats",
"goSettings": "Go to Settings", "goSettings": "Go to Settings",
@@ -1,41 +0,0 @@
{
"navLabel": "Messages",
"popOut": "Open in pop-out",
"expand": "Expand",
"minimize": "Minimize",
"close": "Close",
"conversations": "Conversations",
"new": "New message…",
"newTitle": "New message",
"empty": "No conversations yet. Start one with the New message button.",
"searchPeople": "Search people…",
"noPeople": "No one to message yet.",
"threadEmpty": "No messages yet — say hello.",
"writePlaceholder": "Write a message…",
"send": "Send",
"encrypted": "Encrypted message",
"encryptedHint": "End-to-end encrypted",
"cantDecrypt": "Can't decrypt",
"sendFailed": "Couldn't send your message. Try again.",
"systemReadOnly": "This is an automated message.",
"setupTitle": "Set up secure messaging",
"setupBody": "Messages are end-to-end encrypted. Choose a passphrase to protect your key — it never leaves your device, so no one (not even an admin) can read your conversations.",
"setupWarn": "If you forget this passphrase, your message history can't be recovered.",
"setUp": "Set up",
"unlockTitle": "Unlock messaging",
"unlockBody": "Enter your message passphrase to unlock your conversations on this device.",
"unlock": "Unlock",
"passphrase": "Passphrase",
"passphraseConfirm": "Confirm passphrase",
"passTooShort": "Use at least 6 characters",
"passMismatch": "The passphrases don't match",
"wrongPass": "Wrong passphrase",
"setupFailed": "Couldn't set up secure messaging. Please try again.",
"forgotPass": "Forgot your passphrase?",
"resetTitle": "Reset secure messaging",
"resetWarn": "This permanently erases your entire message history — every conversation, for you and the people you messaged — because it can no longer be decrypted. Then you can set a new passphrase and start fresh. This can't be undone.",
"resetCurrentPass": "Current account password",
"resetNeedPass": "Enter your current account password to confirm.",
"resetButton": "Reset & erase history",
"resetFailed": "Couldn't reset secure messaging. Please try again."
}
-1
View File
@@ -14,7 +14,6 @@
"playlists": "Playlists", "playlists": "Playlists",
"plex": "Plex", "plex": "Plex",
"notifications": "Notifications", "notifications": "Notifications",
"messages": "Messages",
"downloads": "Downloads", "downloads": "Downloads",
"stats": "Stats", "stats": "Stats",
"scheduler": "Scheduler", "scheduler": "Scheduler",
+1 -1
View File
@@ -220,7 +220,7 @@
}, },
"server_one": "The Siftlode server receives this too and keeps it for {{count}} day, so the problem can be tracked down.", "server_one": "The Siftlode server receives this too and keeps it for {{count}} day, so the problem can be tracked down.",
"server_other": "The Siftlode server receives this too and keeps it for {{count}} days, so the problem can be tracked down.", "server_other": "The Siftlode server receives this too and keeps it for {{count}} days, so the problem can be tracked down.",
"never": "Passwords, tokens, email addresses and the content of your messages are never included.", "never": "Passwords, tokens and email addresses are never included.",
"lapse": "After 24 hours it goes back to “Basic” by itself.", "lapse": "After 24 hours it goes back to “Basic” by itself.",
"policy": "Privacy policy", "policy": "Privacy policy",
"cancel": "Cancel", "cancel": "Cancel",
-6
View File
@@ -155,14 +155,8 @@
"link_gone": "Ez a link már nem érhető el", "link_gone": "Ez a link már nem érhető el",
"link_required": "Adj meg egy videólinket vagy -azonosítót", "link_required": "Adj meg egy videólinket vagy -azonosítót",
"media_missing": "A médiafájl nincs a lemezen", "media_missing": "A médiafájl nincs a lemezen",
"message_empty": "Az üzenet üres",
"message_rate": "Túl sok üzenet — várj egy kicsit",
"message_self": "Magadnak nem küldhetsz üzenetet",
"message_too_long": "Az üzenet túl hosszú",
"messaging_set_up": "A biztonságos üzenetküldés már be van állítva",
"name_required": "Adj meg egy nevet", "name_required": "Adj meg egy nevet",
"no_encryption_key": "Titkos értékek tárolásához állítsd be a TOKEN_ENCRYPTION_KEY-t", "no_encryption_key": "Titkos értékek tárolásához állítsd be a TOKEN_ENCRYPTION_KEY-t",
"no_messaging": "Ez a tag még nem állította be az üzenetküldést",
"no_profile": "Nincs használható letöltési profil", "no_profile": "Nincs használható letöltési profil",
"no_subscription_id": "Még nincs rögzített YouTube-feliratkozás — előbb szinkronizáld a feliratkozásaidat", "no_subscription_id": "Még nincs rögzített YouTube-feliratkozás — előbb szinkronizáld a feliratkozásaidat",
"not_authenticated": "Nem vagy bejelentkezve", "not_authenticated": "Nem vagy bejelentkezve",
-1
View File
@@ -27,7 +27,6 @@
"goPlaylists": "Ugrás: Lejátszási listák", "goPlaylists": "Ugrás: Lejátszási listák",
"goPlex": "Ugrás: Plex", "goPlex": "Ugrás: Plex",
"goNotifications": "Ugrás: Értesítések", "goNotifications": "Ugrás: Értesítések",
"goMessages": "Ugrás: Üzenetek",
"goDownloads": "Ugrás: Letöltések", "goDownloads": "Ugrás: Letöltések",
"goStats": "Ugrás: Statisztika", "goStats": "Ugrás: Statisztika",
"goSettings": "Ugrás: Beállítások", "goSettings": "Ugrás: Beállítások",
@@ -1,41 +0,0 @@
{
"navLabel": "Üzenetek",
"popOut": "Megnyitás felugró ablakban",
"expand": "Kinyitás",
"minimize": "Összecsukás",
"close": "Bezárás",
"conversations": "Beszélgetések",
"new": "Új üzenet…",
"newTitle": "Új üzenet",
"empty": "Még nincs beszélgetés. Indíts egyet az Új üzenet gombbal.",
"searchPeople": "Emberek keresése…",
"noPeople": "Még nincs kinek üzenni.",
"threadEmpty": "Még nincs üzenet — köszönj be.",
"writePlaceholder": "Írj egy üzenetet…",
"send": "Küldés",
"encrypted": "Titkosított üzenet",
"encryptedHint": "Végpontig titkosítva",
"cantDecrypt": "Nem fejthető vissza",
"sendFailed": "Nem sikerült elküldeni az üzenetet. Próbáld újra.",
"systemReadOnly": "Ez egy automatikus üzenet.",
"setupTitle": "Biztonságos üzenetküldés beállítása",
"setupBody": "Az üzenetek végpontig titkosítottak. Válassz egy jelszót a kulcsod védelméhez — sosem hagyja el az eszközödet, így senki (még az admin sem) olvashatja a beszélgetéseidet.",
"setupWarn": "Ha elfelejted ezt a jelszót, az üzenet-előzményeid nem állíthatók helyre.",
"setUp": "Beállítás",
"unlockTitle": "Üzenetküldés feloldása",
"unlockBody": "Add meg az üzenet-jelszavadat a beszélgetéseid feloldásához ezen az eszközön.",
"unlock": "Feloldás",
"passphrase": "Jelszó",
"passphraseConfirm": "Jelszó megerősítése",
"passTooShort": "Legalább 6 karakter legyen",
"passMismatch": "A jelszavak nem egyeznek",
"wrongPass": "Hibás jelszó",
"setupFailed": "Nem sikerült beállítani a biztonságos üzenetküldést. Próbáld újra.",
"forgotPass": "Elfelejtetted a jelszavad?",
"resetTitle": "Biztonságos üzenetküldés visszaállítása",
"resetWarn": "Ez véglegesen törli a teljes üzenet-előzményedet — minden beszélgetést, nálad és a partnereidnél is —, mert az többé nem visszafejthető. Utána új jelszót állíthatsz be és tiszta lappal indulhatsz. Ez nem visszavonható.",
"resetCurrentPass": "Jelenlegi fiók-jelszó",
"resetNeedPass": "Add meg a jelenlegi fiók-jelszavad a megerősítéshez.",
"resetButton": "Visszaállítás és előzmény törlése",
"resetFailed": "Nem sikerült visszaállítani a biztonságos üzenetküldést. Próbáld újra."
}
-1
View File
@@ -14,7 +14,6 @@
"playlists": "Listák", "playlists": "Listák",
"plex": "Plex", "plex": "Plex",
"notifications": "Értesítések", "notifications": "Értesítések",
"messages": "Üzenetek",
"downloads": "Letöltések", "downloads": "Letöltések",
"stats": "Statisztika", "stats": "Statisztika",
"scheduler": "Ütemező", "scheduler": "Ütemező",
+1 -1
View File
@@ -220,7 +220,7 @@
}, },
"server_one": "Ezt a Siftlode szervere is megkapja, és {{count}} napig őrzi, hogy a hibát ki lehessen nyomozni.", "server_one": "Ezt a Siftlode szervere is megkapja, és {{count}} napig őrzi, hogy a hibát ki lehessen nyomozni.",
"server_other": "Ezt a Siftlode szervere is megkapja, és {{count}} napig őrzi, hogy a hibát ki lehessen nyomozni.", "server_other": "Ezt a Siftlode szervere is megkapja, és {{count}} napig őrzi, hogy a hibát ki lehessen nyomozni.",
"never": "Jelszó, token, e-mail-cím és az üzeneteid tartalma soha nem kerül bele.", "never": "Jelszó, token és e-mail-cím soha nem kerül bele.",
"lapse": "24 óra múlva magától visszaáll „Alap” szintre.", "lapse": "24 óra múlva magától visszaáll „Alap” szintre.",
"policy": "Adatvédelmi irányelvek", "policy": "Adatvédelmi irányelvek",
"cancel": "Mégse", "cancel": "Mégse",
-26
View File
@@ -972,32 +972,6 @@ html[data-perf="1"] .overlay-backdrop {
transform: none; transform: none;
} }
} }
/* One-shot attention flash for a chat dock window on a new message. Inset glow so the
window's own overflow-hidden doesn't clip it; pulses twice then fades. */
@keyframes chatFlash {
0% {
box-shadow: inset 0 0 0 0 transparent;
}
18% {
box-shadow:
inset 0 0 0 3px var(--accent),
inset 0 0 22px 0 color-mix(in srgb, var(--accent) 55%, transparent);
}
40% {
box-shadow: inset 0 0 0 1px transparent;
}
62% {
box-shadow:
inset 0 0 0 3px var(--accent),
inset 0 0 22px 0 color-mix(in srgb, var(--accent) 55%, transparent);
}
100% {
box-shadow: inset 0 0 0 0 transparent;
}
}
.chat-flash {
animation: chatFlash 1.1s ease-out;
}
/* ===== Color schemes (accent + neutrals), each with dark + light ===== */ /* ===== Color schemes (accent + neutrals), each with dark + light ===== */
-3
View File
@@ -58,8 +58,6 @@ export {
parsePlexFilters, parsePlexFilters,
isIntrinsicReadOnly, isIntrinsicReadOnly,
} from "./api/plex"; } from "./api/plex";
import { messagingApi } from "./api/messaging";
export type * from "./api/messaging";
import { downloadsApi } from "./api/downloads"; import { downloadsApi } from "./api/downloads";
export type * from "./api/downloads"; export type * from "./api/downloads";
import { debugSessionsApi, diagnosticsApi } from "./api/diagnostics"; import { debugSessionsApi, diagnosticsApi } from "./api/diagnostics";
@@ -81,7 +79,6 @@ export const api = {
...quotaApi, ...quotaApi,
...adminApi, ...adminApi,
...plexApi, ...plexApi,
...messagingApi,
...downloadsApi, ...downloadsApi,
...diagnosticsApi, ...diagnosticsApi,
...debugSessionsApi, ...debugSessionsApi,
-1
View File
@@ -30,7 +30,6 @@ const SLICE_MODULES = [
["quotaApi", () => import("./quota")], ["quotaApi", () => import("./quota")],
["adminApi", () => import("./admin")], ["adminApi", () => import("./admin")],
["plexApi", () => import("./plex")], ["plexApi", () => import("./plex")],
["messagingApi", () => import("./messaging")],
["downloadsApi", () => import("./downloads")], ["downloadsApi", () => import("./downloads")],
["diagnosticsApi", () => import("./diagnostics")], ["diagnosticsApi", () => import("./diagnostics")],
["debugSessionsApi", () => import("./diagnostics")], ["debugSessionsApi", () => import("./diagnostics")],
-72
View File
@@ -1,72 +0,0 @@
// Direct-messaging domain: the E2EE key bundle setup/reset, the conversation list, threads, and
// send/unread. Self-contained — types reference only each other, methods only touch core's req.
// (The WebCrypto layer that encrypts/decrypts message bodies lives in e2ee.ts, not here.)
import { req } from "./core";
export interface MessageUser {
id: number;
name: string;
avatar_url: string | null;
}
export interface Message {
id: number;
kind: "user" | "system";
sender_id: number | null;
recipient_id: number;
body: string | null; // plaintext, system messages only
ciphertext: string | null; // base64, E2EE user messages only
iv: string | null;
read_at: string | null;
created_at: string | null;
}
export interface Conversation {
partner: MessageUser;
last_message: Message;
unread: number;
}
// My own E2EE key bundle. When configured, the wrapped blob + params let a device unlock.
export interface MyKeyBundle {
configured: boolean;
public_key?: string;
wrapped_private_key?: string;
salt?: string;
wrap_iv?: string;
key_check?: string;
}
export interface KeySetup {
public_key: string;
wrapped_private_key: string;
salt: string;
wrap_iv: string;
key_check: string;
}
export const messagingApi = {
messageMyKey: (): Promise<MyKeyBundle> => req("/api/messages/keys/me"),
setupMessageKey: (k: KeySetup): Promise<{ configured: boolean }> =>
req("/api/messages/keys", { method: "POST", body: JSON.stringify(k) }),
// Forgotten-passphrase reset: drops the server key (and the now-undecryptable history) so a new
// passphrase can be set. Password accounts must pass their current password; errors shown inline.
resetMessageKey: (currentPassword?: string): Promise<{ configured: boolean }> =>
req(
"/api/messages/keys/reset",
{ method: "POST", body: JSON.stringify({ current_password: currentPassword ?? null }) },
{ quiet: true },
),
messagePublicKey: (userId: number): Promise<{ user_id: number; public_key: string }> =>
req(`/api/messages/keys/${userId}`),
messageUsers: (): Promise<MessageUser[]> => req("/api/messages/users"),
conversations: (): Promise<{ items: Conversation[] }> => req("/api/messages/conversations"),
thread: (partnerId: number): Promise<{ partner: MessageUser; items: Message[] }> =>
req(`/api/messages/conversations/${partnerId}`),
sendMessage: (recipientId: number, ciphertext: string, iv: string): Promise<Message> =>
req("/api/messages", {
method: "POST",
body: JSON.stringify({ recipient_id: recipientId, ciphertext, iv }),
}),
messageUnreadCount: (): Promise<{ count: number }> => req("/api/messages/unread_count"),
};
-52
View File
@@ -1,52 +0,0 @@
// @vitest-environment jsdom
//
// The floating chat dock's store (E32 review: moved onto lib/store's createStore). It restores the
// signed-in user's windows, keeps at most three, flashes a window a message arrives for, and
// persists everything but the transient flash counter.
import { describe, expect, it } from "vitest";
import { act, renderHook } from "@testing-library/react";
import {
closeChat,
initDock,
notifyIncoming,
openChat,
toggleMinimize,
useDockChats,
} from "./chatDock";
import { LS } from "./storage";
const partner = (id: number) => ({ id, name: `P${id}`, avatar_url: null });
describe("the chat dock", () => {
it("restores, caps, flashes and persists the user's windows", () => {
localStorage.setItem(
LS.chatDock(7),
JSON.stringify([{ partnerId: 1, name: "P1", avatar: null, minimized: true }]),
);
const { result } = renderHook(() => useDockChats());
act(() => initDock(7));
expect(result.current).toEqual([
{ partnerId: 1, name: "P1", avatar: null, minimized: true, flash: 0 },
]);
act(() => notifyIncoming(partner(1))); // already open: flash it, keep it minimised
expect(result.current[0]).toMatchObject({ partnerId: 1, minimized: true, flash: 1 });
act(() => {
for (const id of [2, 3, 4]) openChat(partner(id));
});
expect(result.current.map((c) => c.partnerId)).toEqual([4, 3, 2]); // newest first, three at most
act(() => toggleMinimize(3));
act(() => closeChat(2));
expect(result.current.map((c) => [c.partnerId, c.minimized])).toEqual([
[4, false],
[3, true],
]);
const stored = JSON.parse(localStorage.getItem(LS.chatDock(7))!) as object[];
expect(stored).toEqual([
{ partnerId: 4, name: "P4", avatar: null, minimized: false },
{ partnerId: 3, name: "P3", avatar: null, minimized: true },
]);
});
});
-113
View File
@@ -1,113 +0,0 @@
// App-wide store of open floating chat windows (the Messenger-style dock, bottom-right).
// A window can be opened from anywhere (a conversation's "pop out" button, or automatically
// when a message arrives) and stays open across page navigation. The dock state (which chats
// are open + their minimised state) is persisted per user so it survives a reload.
import { LS } from "./storage";
import { createStore } from "./store";
export interface DockChat {
partnerId: number;
name: string;
avatar: string | null;
minimized: boolean;
flash: number; // bumped to trigger a one-shot attention flash on the window
}
type PartnerLike = { id: number; name: string; avatar_url: string | null };
const MAX_OPEN = 3; // keep the dock from overflowing the corner
const dock = createStore<DockChat[]>([]);
let userId: number | null = null;
function storageKey(): string | null {
return userId != null ? LS.chatDock(userId) : null;
}
function persist(): void {
const k = storageKey();
if (!k) return;
try {
// Don't persist the transient flash counter.
localStorage.setItem(k, JSON.stringify(dock.get().map(({ flash, ...c }) => c)));
} catch {
/* ignore quota/serialization errors */
}
}
function emit(next: DockChat[]): void {
dock.set(next);
persist();
}
// Bind the dock to the signed-in user and restore their persisted windows (once per user).
export function initDock(uid: number): void {
if (userId === uid) return;
userId = uid;
let restored: DockChat[] = [];
try {
const raw = localStorage.getItem(storageKey()!);
if (raw)
restored = (JSON.parse(raw) as Omit<DockChat, "flash">[]).map((c) => ({ ...c, flash: 0 }));
} catch {
restored = [];
}
dock.set(restored.slice(0, MAX_OPEN));
}
export function openChat(partner: PartnerLike): void {
const existing = dock.get().find((c) => c.partnerId === partner.id);
if (existing) {
// Re-opening focuses it: ensure expanded and move to the front.
emit([
{ ...existing, minimized: false },
...dock.get().filter((c) => c.partnerId !== partner.id),
]);
return;
}
emit(
[
{
partnerId: partner.id,
name: partner.name,
avatar: partner.avatar_url,
minimized: false,
flash: 0,
},
...dock.get(),
].slice(0, MAX_OPEN),
);
}
// An incoming message arrived from `partner`: open the window if it's closed, otherwise just
// flash it (whether expanded or minimised — don't disturb the minimised state).
export function notifyIncoming(partner: PartnerLike): void {
const existing = dock.get().find((c) => c.partnerId === partner.id);
if (!existing) {
emit(
[
{
partnerId: partner.id,
name: partner.name,
avatar: partner.avatar_url,
minimized: false,
flash: 1,
},
...dock.get(),
].slice(0, MAX_OPEN),
);
return;
}
emit(dock.get().map((c) => (c.partnerId === partner.id ? { ...c, flash: c.flash + 1 } : c)));
}
export function closeChat(partnerId: number): void {
emit(dock.get().filter((c) => c.partnerId !== partnerId));
}
export function toggleMinimize(partnerId: number): void {
emit(dock.get().map((c) => (c.partnerId === partnerId ? { ...c, minimized: !c.minimized } : c)));
}
export function useDockChats(): DockChat[] {
return dock.useValue();
}
-180
View File
@@ -1,180 +0,0 @@
// E32 S2: lib/e2ee was 14% covered, and every way it can break is silent — a message that will not
// decrypt, a passphrase that unlocks nothing, a key that does not survive a reload. Real WebCrypto
// (Node's), a minimal in-memory IndexedDB (the module uses open / one store / get / put / delete),
// and a fresh module per "device", because the unlocked key is module state.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { MyKeyBundle } from "./api";
type E2ee = typeof import("./e2ee");
function memoryIndexedDb() {
const stores = new Map<string, Map<unknown, unknown>>();
const later = (fn: () => void) => setTimeout(fn, 0);
const request = <T>(run: () => T) => {
const r: { result?: T; onsuccess?: () => void; onerror?: () => void } = {};
later(() => {
r.result = run();
r.onsuccess?.();
});
return r;
};
const db = {
createObjectStore: (name: string) => stores.set(name, new Map()),
transaction: (name: string) => {
const tx: { oncomplete?: () => void; onerror?: () => void; objectStore: () => unknown } = {
objectStore: () => {
const s = stores.get(name)!;
const done = <T>(v: T) => (later(() => tx.oncomplete?.()), v);
return {
get: (k: unknown) => request(() => s.get(k)),
put: (v: unknown, k: unknown) => done(s.set(k, v)),
delete: (k: unknown) => done(s.delete(k)),
};
},
};
return tx;
},
};
return {
open: () => {
const r: { result?: unknown; onupgradeneeded?: () => void; onsuccess?: () => void } = {};
later(() => {
r.result = db;
if (stores.size === 0) r.onupgradeneeded?.();
r.onsuccess?.();
});
return r;
},
};
}
async function freshDevice(): Promise<E2ee> {
vi.resetModules();
return import("./e2ee");
}
const bundleOf = (k: Awaited<ReturnType<E2ee["setup"]>>): MyKeyBundle =>
({ ...k, has_key: true }) as unknown as MyKeyBundle;
beforeEach(() => {
vi.stubGlobal("indexedDB", memoryIndexedDb());
});
afterEach(() => {
vi.unstubAllGlobals();
});
describe("e2ee", () => {
it("a message one user encrypts, the other decrypts — and nobody else", async () => {
const alice = await freshDevice();
const aliceKey = await alice.setup(1, "alice passphrase");
const bob = await freshDevice();
const bobKey = await bob.setup(2, "bob passphrase");
const msg = await alice.encryptFor(2, bobKey.public_key, "szia, Bob — ő 🎬");
expect(msg.ciphertext).not.toContain("Bob");
expect(await bob.decryptFrom(1, aliceKey.public_key, msg.ciphertext, msg.iv)).toBe(
"szia, Bob — ő 🎬",
);
const eve = await freshDevice();
await eve.setup(3, "eve passphrase");
await expect(eve.decryptFrom(1, aliceKey.public_key, msg.ciphertext, msg.iv)).rejects.toThrow();
}, 30_000);
it("each message gets its own IV", async () => {
const alice = await freshDevice();
await alice.setup(1, "p");
const bob = await freshDevice();
const bobKey = await bob.setup(2, "q");
const a = await alice.encryptFor(2, bobKey.public_key, "same");
const b = await alice.encryptFor(2, bobKey.public_key, "same");
expect(a.iv).not.toBe(b.iv);
expect(a.ciphertext).not.toBe(b.ciphertext);
}, 30_000);
it("a new device unlocks with the passphrase and refuses a wrong one", async () => {
const first = await freshDevice();
const bundle = bundleOf(await first.setup(1, "the right one"));
vi.stubGlobal("indexedDB", memoryIndexedDb()); // another browser: nothing stored
const second = await freshDevice();
expect(await second.loadFromDevice(1)).toBe(false);
await expect(second.unlock(1, "a wrong one", bundle)).rejects.toThrow();
expect(second.isUnlocked()).toBe(false);
await second.unlock(1, "the right one", bundle);
expect(second.isUnlocked()).toBe(true);
}, 30_000);
it("the unlocked key survives a reload on the same device, until it is reset", async () => {
const before = await freshDevice();
await before.setup(7, "p");
const after = await freshDevice(); // a reload: module state gone, IndexedDB kept
expect(after.isUnlocked()).toBe(false);
const seen = vi.fn();
after.subscribeUnlock(seen);
expect(await after.loadFromDevice(7)).toBe(true);
expect(after.isUnlocked()).toBe(true);
expect(seen).toHaveBeenCalled();
await after.resetLocal(7);
expect(after.isUnlocked()).toBe(false);
const again = await freshDevice();
expect(await again.loadFromDevice(7)).toBe(false);
}, 30_000);
it("an incomplete bundle and a locked device are refused, not guessed around", async () => {
const e = await freshDevice();
await expect(e.unlock(1, "p", { has_key: true } as unknown as MyKeyBundle)).rejects.toThrow(
"incomplete key bundle",
);
await expect(e.encryptFor(2, "AAAA", "x")).rejects.toThrow("locked");
});
it("the key check uses its own nonce, never the wrap IV (GCM nonce reuse)", async () => {
const e = await freshDevice();
const k = await e.setup(1, "p");
// Re-derive the wrap key the way the module does; then the key check must NOT open under the
// wrap IV (it would, were the nonce reused), while the wrapped key does.
const raw = (s: string) => Uint8Array.from(atob(s), (c) => c.charCodeAt(0));
const base = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode("p"),
"PBKDF2",
false,
["deriveKey"],
);
const wrapKey = await crypto.subtle.deriveKey(
{ name: "PBKDF2", salt: raw(k.salt), iterations: 600_000, hash: "SHA-256" },
base,
{ name: "AES-GCM", length: 256 },
false,
["decrypt"],
);
const open = (ct: string) =>
crypto.subtle.decrypt({ name: "AES-GCM", iv: raw(k.wrap_iv) }, wrapKey, raw(ct));
await expect(open(k.wrapped_private_key)).resolves.toBeDefined();
await expect(open(k.key_check)).rejects.toThrow();
}, 30_000);
it("a partner's rotated key is used only after forgetConv", async () => {
const alice = await freshDevice();
const aliceKey = await alice.setup(1, "p");
const bobOld = await freshDevice();
const oldKey = await bobOld.setup(2, "q");
await alice.encryptFor(2, oldKey.public_key, "warm the cache");
const bobNew = await freshDevice(); // Bob reset his passphrase: a new keypair
const newKey = await bobNew.setup(2, "r");
// Cached by partner id alone: without forgetConv the OLD conversation key is still used…
const stale = await alice.encryptFor(2, newKey.public_key, "after the reset");
await expect(
bobNew.decryptFrom(1, aliceKey.public_key, stale.ciphertext, stale.iv),
).rejects.toThrow();
// …and after it, the new one.
alice.forgetConv(2);
const fresh = await alice.encryptFor(2, newKey.public_key, "after the reset");
expect(await bobNew.decryptFrom(1, aliceKey.public_key, fresh.ciphertext, fresh.iv)).toBe(
"after the reset",
);
}, 60_000);
});
-253
View File
@@ -1,253 +0,0 @@
// End-to-end encryption for direct messages (notification module phase 2).
//
// Contract (validated against the backend): each user has an ECDH P-256 keypair. The private
// key is wrapped in the browser with an AES-GCM key derived (PBKDF2) from a message passphrase
// the server never sees; only the wrapped blob is uploaded. A conversation key is derived from
// ECDH(my private, partner public) → HKDF → AES-GCM, and each message is AES-GCM encrypted with
// a random IV. The server stores only ciphertext, so not even an admin can read a conversation.
//
// The unlocked private key is persisted per-device as a NON-EXTRACTABLE CryptoKey in IndexedDB,
// so the passphrase is needed once per device, then survives reloads (cleared with browser data).
import type { KeySetup, MyKeyBundle } from "./api";
const subtle = crypto.subtle;
const enc = new TextEncoder();
const dec = new TextDecoder();
const PBKDF2_ITERS = 600_000;
const HKDF_INFO = "siftlode-dm-v1";
const b64 = (buf: ArrayBuffer | Uint8Array): string => {
const bytes = buf instanceof Uint8Array ? buf : new Uint8Array(buf);
let s = "";
for (const b of bytes) s += String.fromCharCode(b);
return btoa(s);
};
const ub64 = (s: string): Uint8Array => Uint8Array.from(atob(s), (c) => c.charCodeAt(0));
// WebCrypto byte inputs: the DOM lib types want BufferSource backed by a plain ArrayBuffer;
// our Uint8Arrays/ArrayBuffers are functionally that, so coerce at the call sites.
const bsrc = (x: Uint8Array | ArrayBuffer): BufferSource => x as BufferSource;
// --- module state ---
let privKey: CryptoKey | null = null; // current unlocked (non-extractable) private key
const convKeys = new Map<number, CryptoKey>(); // partnerId → derived AES key
// Unlock state is shared app-wide (the Messages page and the floating chat dock both observe
// it), so expose a subscription for useSyncExternalStore.
const unlockSubs = new Set<() => void>();
function emitUnlock(): void {
for (const cb of unlockSubs) cb();
}
export function subscribeUnlock(cb: () => void): () => void {
unlockSubs.add(cb);
return () => unlockSubs.delete(cb);
}
export function isUnlocked(): boolean {
return !!privKey;
}
// --- IndexedDB (per-device persistence of the unlocked private key) ---
const DB_NAME = "siftlode-e2ee";
const STORE = "privkeys";
function idb(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, 1);
req.onupgradeneeded = () => req.result.createObjectStore(STORE);
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
async function idbGet(userId: number): Promise<CryptoKey | null> {
const db = await idb();
return new Promise((resolve) => {
const r = db.transaction(STORE, "readonly").objectStore(STORE).get(userId);
r.onsuccess = () => resolve((r.result as CryptoKey) ?? null);
r.onerror = () => resolve(null);
});
}
async function idbPut(userId: number, key: CryptoKey): Promise<void> {
const db = await idb();
await new Promise<void>((resolve) => {
const tx = db.transaction(STORE, "readwrite");
tx.objectStore(STORE).put(key, userId);
tx.oncomplete = () => resolve();
tx.onerror = () => resolve();
});
}
export async function clearDevice(userId: number): Promise<void> {
const db = await idb();
await new Promise<void>((resolve) => {
const tx = db.transaction(STORE, "readwrite");
tx.objectStore(STORE).delete(userId);
tx.oncomplete = () => resolve();
tx.onerror = () => resolve();
});
}
// Wipe this device's E2EE identity: forget the unlocked key + every derived conversation key,
// delete the persisted copy, and notify observers so the gate re-appears. Used by the passphrase
// reset, which also drops the server key so the next screen is a fresh setup.
export async function resetLocal(userId: number): Promise<void> {
privKey = null;
convKeys.clear();
await clearDevice(userId);
emitUnlock();
}
// --- key derivation ---
async function wrapKeyFrom(passphrase: string, salt: Uint8Array): Promise<CryptoKey> {
const base = await subtle.importKey("raw", bsrc(enc.encode(passphrase)), "PBKDF2", false, [
"deriveKey",
]);
return subtle.deriveKey(
{ name: "PBKDF2", salt: bsrc(salt), iterations: PBKDF2_ITERS, hash: "SHA-256" },
base,
{ name: "AES-GCM", length: 256 },
false,
["encrypt", "decrypt"],
);
}
async function importPrivate(pkcs8: ArrayBuffer): Promise<CryptoKey> {
// Non-extractable: usable for deriveBits but can never be exported again.
return subtle.importKey("pkcs8", bsrc(pkcs8), { name: "ECDH", namedCurve: "P-256" }, false, [
"deriveBits",
]);
}
// Try to restore the unlocked key from this device's storage. Returns true if unlocked.
export async function loadFromDevice(userId: number): Promise<boolean> {
const stored = await idbGet(userId);
if (stored) {
privKey = stored;
emitUnlock();
return true;
}
return false;
}
// Install a freshly-imported private key as the current identity: drop any derived conversation
// keys, persist it to this device, and notify unlock subscribers. Shared by setup() and unlock().
async function installKey(userId: number, key: CryptoKey): Promise<void> {
privKey = key;
convKeys.clear();
await idbPut(userId, key);
emitUnlock();
}
// First-run setup: generate a keypair, wrap the private key with the passphrase, persist a
// non-extractable copy locally, and return the bundle to upload to the server.
export async function setup(userId: number, passphrase: string): Promise<KeySetup> {
const kp = await subtle.generateKey({ name: "ECDH", namedCurve: "P-256" }, true, ["deriveBits"]);
const salt = crypto.getRandomValues(new Uint8Array(16));
const wrapIv = crypto.getRandomValues(new Uint8Array(12));
const wrapKey = await wrapKeyFrom(passphrase, salt);
const pkcs8 = await subtle.exportKey("pkcs8", kp.privateKey);
const wrapped = await subtle.encrypt({ name: "AES-GCM", iv: bsrc(wrapIv) }, wrapKey, bsrc(pkcs8));
// key_check is retained for the upload schema but MUST use its own nonce: reusing wrapIv under
// wrapKey would be GCM nonce-reuse (leaks keystream + enables GHASH forgery). It is no longer a
// verification oracle either — unlock() authenticates the passphrase via the wrapped-key GCM tag.
const checkIv = crypto.getRandomValues(new Uint8Array(12));
const keyCheck = await subtle.encrypt(
{ name: "AES-GCM", iv: bsrc(checkIv) },
wrapKey,
bsrc(enc.encode("ok")),
);
const spki = await subtle.exportKey("spki", kp.publicKey);
await installKey(userId, await importPrivate(pkcs8));
return {
public_key: b64(spki),
wrapped_private_key: b64(wrapped),
salt: b64(salt),
wrap_iv: b64(wrapIv),
key_check: b64(keyCheck),
};
}
// Unlock on a device from the server blob; throws if the passphrase is wrong.
export async function unlock(
userId: number,
passphrase: string,
bundle: MyKeyBundle,
): Promise<void> {
if (!bundle.wrapped_private_key || !bundle.salt || !bundle.wrap_iv || !bundle.key_check) {
throw new Error("incomplete key bundle");
}
const wrapKey = await wrapKeyFrom(passphrase, ub64(bundle.salt));
// Wrong passphrase → the AES-GCM auth tag fails on this decrypt. (No separate key_check oracle:
// that was redundant with this tag and formerly shared wrapIv — see setup().)
const pkcs8 = await subtle.decrypt(
{ name: "AES-GCM", iv: bsrc(ub64(bundle.wrap_iv)) },
wrapKey,
bsrc(ub64(bundle.wrapped_private_key)),
);
await installKey(userId, await importPrivate(pkcs8));
}
// Drop the cached derived conversation key for a partner. convKeyFor caches by partnerId alone, so
// after a partner rotates their public key (a passphrase reset) the caller must call this before
// re-deriving with the fresh key — otherwise the stale key would be returned.
export function forgetConv(partnerId: number): void {
convKeys.delete(partnerId);
}
// --- per-conversation encryption ---
async function convKeyFor(partnerId: number, partnerPubB64: string): Promise<CryptoKey> {
const cached = convKeys.get(partnerId);
if (cached) return cached;
if (!privKey) throw new Error("locked");
const pub = await subtle.importKey(
"spki",
bsrc(ub64(partnerPubB64)),
{ name: "ECDH", namedCurve: "P-256" },
false,
[],
);
const bits = await subtle.deriveBits({ name: "ECDH", public: pub }, privKey, 256);
const hk = await subtle.importKey("raw", bits, "HKDF", false, ["deriveKey"]);
const key = await subtle.deriveKey(
{
name: "HKDF",
hash: "SHA-256",
salt: bsrc(new Uint8Array(0)),
info: bsrc(enc.encode(HKDF_INFO)),
},
hk,
{ name: "AES-GCM", length: 256 },
false,
["encrypt", "decrypt"],
);
convKeys.set(partnerId, key);
return key;
}
export async function encryptFor(
partnerId: number,
partnerPubB64: string,
plaintext: string,
): Promise<{ ciphertext: string; iv: string }> {
const key = await convKeyFor(partnerId, partnerPubB64);
const iv = crypto.getRandomValues(new Uint8Array(12));
const ct = await subtle.encrypt(
{ name: "AES-GCM", iv: bsrc(iv) },
key,
bsrc(enc.encode(plaintext)),
);
return { ciphertext: b64(ct), iv: b64(iv) };
}
export async function decryptFrom(
partnerId: number,
partnerPubB64: string,
ciphertext: string,
iv: string,
): Promise<string> {
const key = await convKeyFor(partnerId, partnerPubB64);
const pt = await subtle.decrypt(
{ name: "AES-GCM", iv: bsrc(ub64(iv)) },
key,
bsrc(ub64(ciphertext)),
);
return dec.decode(pt);
}
+6 -67
View File
@@ -270,7 +270,7 @@
"name": "XMLHttpRequest" "name": "XMLHttpRequest"
}, },
{ {
"message": "Use the logged request layer: `WebSocket` belongs to src/lib/messagesSocket.ts only (E22 S4 — a request outside it never reaches the client log).", "message": "No `WebSocket` without a logged owner module (E22 S4).",
"name": "WebSocket" "name": "WebSocket"
}, },
{ {
@@ -338,7 +338,7 @@
"name": "fetch" "name": "fetch"
}, },
{ {
"message": "Use the logged request layer: `WebSocket` belongs to src/lib/messagesSocket.ts only (E22 S4 — a request outside it never reaches the client log).", "message": "No `WebSocket` without a logged owner module (E22 S4).",
"name": "WebSocket" "name": "WebSocket"
}, },
{ {
@@ -386,7 +386,7 @@
"name": "XMLHttpRequest" "name": "XMLHttpRequest"
}, },
{ {
"message": "Use the logged request layer: `WebSocket` belongs to src/lib/messagesSocket.ts only (E22 S4 — a request outside it never reaches the client log).", "message": "No `WebSocket` without a logged owner module (E22 S4).",
"name": "WebSocket" "name": "WebSocket"
}, },
{ {
@@ -408,54 +408,6 @@
}, },
"settings": null "settings": null
}, },
"lib/messagesSocket.ts": {
"languageOptions": {
"ecmaVersion": 2022,
"globals": "1169 names, sha1 aee621727a03",
"parser": "typescript-eslint/parser",
"parserOptions": { "projectService": true, "tsconfigRootDir": "." },
"sourceType": "module"
},
"linterOptions": { "reportUnusedDisableDirectives": 2 },
"notApplied": [],
"plugins": [
"@",
"@eslint-community/eslint-comments",
"@typescript-eslint",
"local",
"react-hooks",
"react-refresh"
],
"rules": {
"no-restricted-globals": [
2,
{
"message": "Use the logged request layer: `fetch` belongs to src/lib/http.ts only (E22 S4 — a request outside it never reaches the client log).",
"name": "fetch"
},
{
"message": "Use the logged request layer: `XMLHttpRequest` belongs to src/lib/api/core.ts only (E22 S4 — a request outside it never reaches the client log).",
"name": "XMLHttpRequest"
},
{
"message": "No `EventSource` without a logged owner module (E22 S4).",
"name": "EventSource"
}
],
"no-restricted-imports": [
2,
{
"patterns": [
{
"message": "lib/ sits below components/: it may not import from components/, not even a type. Move the shared piece down into lib/.",
"regex": "^(\\.\\./)+components(/|$)"
}
]
}
]
},
"settings": null
},
"src": { "src": {
"languageOptions": { "languageOptions": {
"ecmaVersion": 2022, "ecmaVersion": 2022,
@@ -852,7 +804,7 @@
"name": "XMLHttpRequest" "name": "XMLHttpRequest"
}, },
{ {
"message": "Use the logged request layer: `WebSocket` belongs to src/lib/messagesSocket.ts only (E22 S4 — a request outside it never reaches the client log).", "message": "No `WebSocket` without a logged owner module (E22 S4).",
"name": "WebSocket" "name": "WebSocket"
}, },
{ {
@@ -1159,7 +1111,7 @@
"name": "XMLHttpRequest" "name": "XMLHttpRequest"
}, },
{ {
"message": "Use the logged request layer: `WebSocket` belongs to src/lib/messagesSocket.ts only (E22 S4 — a request outside it never reaches the client log).", "message": "No `WebSocket` without a logged owner module (E22 S4).",
"name": "WebSocket" "name": "WebSocket"
}, },
{ {
@@ -1362,7 +1314,7 @@
"name": "XMLHttpRequest" "name": "XMLHttpRequest"
}, },
{ {
"message": "Use the logged request layer: `WebSocket` belongs to src/lib/messagesSocket.ts only (E22 S4 — a request outside it never reaches the client log).", "message": "No `WebSocket` without a logged owner module (E22 S4).",
"name": "WebSocket" "name": "WebSocket"
}, },
{ {
@@ -1465,7 +1417,6 @@
"src/lib/channelOrder.dom.test.ts", "src/lib/channelOrder.dom.test.ts",
"src/lib/channelWriteFailures.dom.test.tsx", "src/lib/channelWriteFailures.dom.test.tsx",
"src/lib/channelsUrlState.dom.test.ts", "src/lib/channelsUrlState.dom.test.ts",
"src/lib/chatDock.dom.test.ts",
"src/lib/chipLane.contract.test.ts", "src/lib/chipLane.contract.test.ts",
"src/lib/chipOverflow.test.ts", "src/lib/chipOverflow.test.ts",
"src/lib/columnSort.test.ts", "src/lib/columnSort.test.ts",
@@ -1478,7 +1429,6 @@
"src/lib/downloadActions.test.ts", "src/lib/downloadActions.test.ts",
"src/lib/downloadFailure.test.ts", "src/lib/downloadFailure.test.ts",
"src/lib/downloads.test.ts", "src/lib/downloads.test.ts",
"src/lib/e2ee.test.ts",
"src/lib/fadeEdge.contract.test.ts", "src/lib/fadeEdge.contract.test.ts",
"src/lib/feedSort.yt.test.ts", "src/lib/feedSort.yt.test.ts",
"src/lib/focusSafe.contract.test.ts", "src/lib/focusSafe.contract.test.ts",
@@ -1512,7 +1462,6 @@
"src/lib/log.test.ts", "src/lib/log.test.ts",
"src/lib/logHooks.dom.test.ts", "src/lib/logHooks.dom.test.ts",
"src/lib/logsUrlState.test.ts", "src/lib/logsUrlState.test.ts",
"src/lib/messagesSocket.test.ts",
"src/lib/moveInList.test.ts", "src/lib/moveInList.test.ts",
"src/lib/mutationFailures.contract.test.ts", "src/lib/mutationFailures.contract.test.ts",
"src/lib/navPosition.wiring.contract.test.ts", "src/lib/navPosition.wiring.contract.test.ts",
@@ -1588,7 +1537,6 @@
], ],
"lib/api/core.ts": ["src/lib/api/core.ts"], "lib/api/core.ts": ["src/lib/api/core.ts"],
"lib/http.ts": ["src/lib/http.ts"], "lib/http.ts": ["src/lib/http.ts"],
"lib/messagesSocket.ts": ["src/lib/messagesSocket.ts"],
"src": [ "src": [
"src/App.tsx", "src/App.tsx",
"src/components/About.tsx", "src/components/About.tsx",
@@ -1613,8 +1561,6 @@
"src/components/ChannelTags.tsx", "src/components/ChannelTags.tsx",
"src/components/Channels.tsx", "src/components/Channels.tsx",
"src/components/ChannelsProvider.tsx", "src/components/ChannelsProvider.tsx",
"src/components/ChatDock.tsx",
"src/components/ChatThread.tsx",
"src/components/ConfigPanel.tsx", "src/components/ConfigPanel.tsx",
"src/components/ConfirmProvider.tsx", "src/components/ConfirmProvider.tsx",
"src/components/DateFilterSelect.tsx", "src/components/DateFilterSelect.tsx",
@@ -1635,12 +1581,10 @@
"src/components/Header.tsx", "src/components/Header.tsx",
"src/components/Home.tsx", "src/components/Home.tsx",
"src/components/IssueComposer.tsx", "src/components/IssueComposer.tsx",
"src/components/KeyGate.tsx",
"src/components/LanguageSwitcher.tsx", "src/components/LanguageSwitcher.tsx",
"src/components/LibraryPlayer.tsx", "src/components/LibraryPlayer.tsx",
"src/components/LogLine.tsx", "src/components/LogLine.tsx",
"src/components/MediaResumeButtons.tsx", "src/components/MediaResumeButtons.tsx",
"src/components/Messages.tsx",
"src/components/MyLogViewer.tsx", "src/components/MyLogViewer.tsx",
"src/components/NavSidebar.tsx", "src/components/NavSidebar.tsx",
"src/components/NavigationProvider.tsx", "src/components/NavigationProvider.tsx",
@@ -1800,7 +1744,6 @@
"src/components/Home.pin.dom.test.tsx", "src/components/Home.pin.dom.test.tsx",
"src/components/IssueComposer.dom.test.tsx", "src/components/IssueComposer.dom.test.tsx",
"src/components/LibraryPlayer.dom.test.tsx", "src/components/LibraryPlayer.dom.test.tsx",
"src/components/Messages.keyboard.dom.test.tsx",
"src/components/MyLogViewer.dom.test.tsx", "src/components/MyLogViewer.dom.test.tsx",
"src/components/NavigationProvider.dom.test.tsx", "src/components/NavigationProvider.dom.test.tsx",
"src/components/NotificationsPanel.menu.dom.test.tsx", "src/components/NotificationsPanel.menu.dom.test.tsx",
@@ -1914,7 +1857,6 @@
"src/lib/api/discover.ts", "src/lib/api/discover.ts",
"src/lib/api/downloads.ts", "src/lib/api/downloads.ts",
"src/lib/api/feed.ts", "src/lib/api/feed.ts",
"src/lib/api/messaging.ts",
"src/lib/api/notifications.ts", "src/lib/api/notifications.ts",
"src/lib/api/playlists.ts", "src/lib/api/playlists.ts",
"src/lib/api/plex.ts", "src/lib/api/plex.ts",
@@ -1933,7 +1875,6 @@
"src/lib/channelLayout.ts", "src/lib/channelLayout.ts",
"src/lib/channelOrder.ts", "src/lib/channelOrder.ts",
"src/lib/channelsUrlState.ts", "src/lib/channelsUrlState.ts",
"src/lib/chatDock.ts",
"src/lib/chipOverflow.ts", "src/lib/chipOverflow.ts",
"src/lib/clipboard.ts", "src/lib/clipboard.ts",
"src/lib/collate.ts", "src/lib/collate.ts",
@@ -1947,7 +1888,6 @@
"src/lib/downloadActions.ts", "src/lib/downloadActions.ts",
"src/lib/downloadFailure.ts", "src/lib/downloadFailure.ts",
"src/lib/downloads.ts", "src/lib/downloads.ts",
"src/lib/e2ee.ts",
"src/lib/entryUrl.ts", "src/lib/entryUrl.ts",
"src/lib/errorDialog.ts", "src/lib/errorDialog.ts",
"src/lib/feedSort.ts", "src/lib/feedSort.ts",
@@ -1973,7 +1913,6 @@
"src/lib/logsUrlState.ts", "src/lib/logsUrlState.ts",
"src/lib/mediaLog.ts", "src/lib/mediaLog.ts",
"src/lib/mediaSource.ts", "src/lib/mediaSource.ts",
"src/lib/messaging.ts",
"src/lib/modules.ts", "src/lib/modules.ts",
"src/lib/moveInList.ts", "src/lib/moveInList.ts",
"src/lib/nav.ts", "src/lib/nav.ts",
+1 -1
View File
@@ -3,7 +3,7 @@
// //
// Two primitives share the existing page-history (App stamps `sfPage` in history.state): // Two primitives share the existing page-history (App stamps `sfPage` in history.state):
// - useHistorySubview: a module's active sub-view rides in history.state._sub, so Back inside // - useHistorySubview: a module's active sub-view rides in history.state._sub, so Back inside
// a module (e.g. Messages thread → list) returns to the parent view first. // a module (e.g. the sign-in card's "forgot password" → sign-in) returns to the parent view first.
// - useBackToClose: while an overlay/modal is mounted it occupies one history entry; Back // - useBackToClose: while an overlay/modal is mounted it occupies one history entry; Back
// closes the topmost one. A module-level stack keeps nesting correct — closing one modal by // closes the topmost one. A module-level stack keeps nesting correct — closing one modal by
// its own button pops exactly its entry without tripping the modals underneath. // its own button pops exactly its entry without tripping the modals underneath.
-1
View File
@@ -116,7 +116,6 @@ export const SHORTCUTS = [
labelKey: "keys.goNotifications", labelKey: "keys.goNotifications",
page: "notifications", page: "notifications",
}, },
{ id: "goMessages", scope: "page", keys: ["g m"], labelKey: "keys.goMessages", page: "messages" },
{ {
id: "goDownloads", id: "goDownloads",
scope: "page", scope: "page",
+1 -1
View File
@@ -11,7 +11,7 @@
// REDACTION HAPPENS HERE, at capture, not at the call site — the same rules as the server's handler, // REDACTION HAPPENS HERE, at capture, not at the call site — the same rules as the server's handler,
// held to them by a fixture both test suites run (`backend/tests/fixtures/log_redaction_cases.json`). // held to them by a fixture both test suites run (`backend/tests/fixtures/log_redaction_cases.json`).
// A secret cannot leak by forgetting to scrub it at one call site, and a new call site is safe by // A secret cannot leak by forgetting to scrub it at one call site, and a new call site is safe by
// default. Never kept, at any level: passwords, tokens, cookies, secret config, E2EE material, email // default. Never kept, at any level: passwords, tokens, cookies, secret config, ciphertext, email
// addresses. What a level ADDS (filter values at DEBUG, search strings at TRACE) is decided by the // addresses. What a level ADDS (filter values at DEBUG, search strings at TRACE) is decided by the
// call site asking `enabled(level)` before it builds the attribute. // call site asking `enabled(level)` before it builds the attribute.
// //
-74
View File
@@ -1,74 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("./api", () => ({ getActiveAccount: () => 7 }));
// A WebSocket double the test drives: each instance is recorded, and its events are fired by hand.
class FakeSocket {
static all: FakeSocket[] = [];
url: string;
onopen: (() => void) | null = null;
onmessage: ((ev: { data: string }) => void) | null = null;
onclose: ((ev: { code: number; wasClean: boolean }) => void) | null = null;
onerror: (() => void) | null = null;
closed = false;
constructor(url: string) {
this.url = url;
FakeSocket.all.push(this);
}
close() {
this.closed = true; // the real close event arrives later, on its own task — fired by the test
}
}
beforeEach(() => {
vi.useFakeTimers();
vi.resetModules();
FakeSocket.all = [];
vi.stubGlobal("WebSocket", FakeSocket);
vi.stubGlobal("location", { protocol: "https:", host: "app.example" });
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});
const frame = (sock: FakeSocket) =>
sock.onmessage?.({ data: JSON.stringify({ type: "message", message: { id: 1 } }) });
describe("messagesSocket", () => {
it("a socket closed on unsubscribe cannot take over from the one a re-subscribe opened", async () => {
const { onMessage } = await import("./messagesSocket");
const first = vi.fn();
onMessage(first)(); // subscribe, then leave (a remount / StrictMode's double effect)
const got = vi.fn();
onMessage(got);
const [old, current] = FakeSocket.all;
expect(old!.closed).toBe(true);
old!.onclose?.({ code: 1000, wasClean: true }); // the OLD socket's close lands late
vi.advanceTimersByTime(60_000);
expect(FakeSocket.all).toHaveLength(2); // no third socket
frame(current!);
expect(got).toHaveBeenCalledOnce();
});
it("reconnects when the current socket drops, and logs it without the account query", async () => {
// Imported after resetModules, like the socket module: the ring it writes to is this instance.
const { onMessage } = await import("./messagesSocket");
const { events } = await import("./log");
onMessage(() => {});
const [sock] = FakeSocket.all;
expect(sock!.url).toBe("wss://app.example/api/messages/ws?account=7");
sock!.onopen?.();
sock!.onclose?.({ code: 1006, wasClean: false });
vi.advanceTimersByTime(1000);
expect(FakeSocket.all).toHaveLength(2);
const logged = events().map((e) => [e.event, e.level, e.attrs?.path]);
expect(logged).toEqual([
["ws.open", "INFO", "/api/messages/ws"],
["ws.close", "WARNING", "/api/messages/ws"],
]);
expect(JSON.stringify(events())).not.toContain("account");
});
});
-137
View File
@@ -1,137 +0,0 @@
// Live message delivery over a WebSocket. The server pushes a {type:"message", message} frame
// to all of a user's open tabs when a message is stored; subscribers react (e.g. refetch the
// thread + conversations). Auto-reconnects with backoff; a low-frequency poll elsewhere is the
// safety net if the socket is down.
import { getActiveAccount, type Message, type MessageUser } from "./api";
import { log } from "./log";
// What the client log records of the socket: its lifecycle, and at DEBUG each frame's TYPE. Never a
// frame's content — chat plaintext stays out of every log structurally, not by redaction. The path
// is fixed, without the `?account=` query.
const WS_PATH = "/api/messages/ws";
// A pushed message plus both parties, so the dock can open/flash the right window.
interface IncomingMessage {
message: Message;
from?: MessageUser;
to?: MessageUser;
}
type Handler = (e: IncomingMessage) => void;
type UnreadHandler = () => void;
const handlers = new Set<Handler>();
const unreadHandlers = new Set<UnreadHandler>();
let ws: WebSocket | null = null;
let started = false;
let backoff = 1000;
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
function url(): string {
const proto = location.protocol === "https:" ? "wss" : "ws";
// A WebSocket can't carry the X-Siftlode-Account header, so the per-tab account rides in the
// query string (validated against the browser wallet server-side). The account is read once
// here: switching accounts always goes through location.reload() (NavSidebar), which rebuilds
// this module and reopens the socket, so it's intentionally fixed for the socket's lifetime.
const account = getActiveAccount();
const qs = account != null ? `?account=${account}` : "";
return `${proto}://${location.host}${WS_PATH}${qs}`;
}
function open() {
let sock: WebSocket;
try {
sock = new WebSocket(url());
} catch (e) {
log.warn("ws.failed", "could not open the socket", { path: WS_PATH, message: String(e) });
schedule();
return;
}
ws = sock;
// Every handler checks it still belongs to the CURRENT socket. A socket closed by the last
// subscriber leaving closes asynchronously: when a subscriber came back in between (a remount,
// StrictMode's double effect) a newer socket is already open, and the old one's close used to
// clear that newer reference and schedule a THIRD — two live sockets, every message delivered twice.
sock.onopen = () => {
if (ws !== sock) return;
log.info("ws.open", "socket open", { path: WS_PATH });
backoff = 1000;
};
sock.onmessage = (ev) => {
if (ws !== sock) return;
try {
const data = JSON.parse(ev.data);
log.debug("ws.frame", "socket frame", { path: WS_PATH, type: String(data?.type) });
if (data?.type === "message" && data.message) {
const e: IncomingMessage = {
message: data.message as Message,
from: data.from,
to: data.to,
};
for (const h of handlers) h(e);
} else if (data?.type === "unread") {
// Sent to a user's OTHER tabs when they read a thread elsewhere, so the badge updates live.
for (const h of unreadHandlers) h();
}
} catch {
/* ignore malformed frames */
}
};
sock.onclose = (ev) => {
const current = ws === sock;
// A socket we let go of (the last subscriber left) closing is routine; the current one closing
// is the server or the network going away, and the reconnect that follows is worth seeing.
(current ? log.warn : log.info)("ws.close", "socket closed", {
path: WS_PATH,
code: ev.code,
clean: ev.wasClean,
reconnect_ms: current && started ? backoff : null,
});
if (!current) return;
ws = null;
if (started) schedule();
};
sock.onerror = () => {
sock.close();
};
}
function schedule() {
if (reconnectTimer) return;
reconnectTimer = setTimeout(() => {
reconnectTimer = null;
if (started) open();
}, backoff);
backoff = Math.min(backoff * 2, 30000);
}
// Subscribe to live messages. Opens the socket on the first subscriber and closes it when the
// last one leaves.
export function onMessage(h: Handler): () => void {
handlers.add(h);
if (!started) {
started = true;
open();
}
return () => {
handlers.delete(h);
if (handlers.size === 0) {
started = false;
if (reconnectTimer) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
}
ws?.close();
ws = null;
}
};
}
// Subscribe to "your unread changed elsewhere" pings (a read on another tab/device). The socket is
// already opened/closed by onMessage subscribers (the app-wide ChatDock keeps one), so this only
// registers a callback — it does not open the socket on its own.
export function onUnread(h: UnreadHandler): () => void {
unreadHandlers.add(h);
return () => {
unreadHandlers.delete(h);
};
}
-66
View File
@@ -1,66 +0,0 @@
// Shared messaging helpers used by both the Messages page and the floating chat dock.
import { useSyncExternalStore } from "react";
import { qk } from "./queryKeys";
import { useQuery } from "@tanstack/react-query";
import { api } from "./api";
import { forgetConv, isUnlocked, subscribeUnlock } from "./e2ee";
export const SYSTEM_ID = 0; // synthetic "Siftlode" system conversation partner id
// Safety-net poll interval for message queries; live updates arrive over the WebSocket.
export const POLL_MS = 20000;
// Cache of partner public keys (the server is the directory). Keys are stable in normal use, but a
// partner can rotate theirs via a passphrase reset — refreshPartnerPub() evicts a stale entry (and
// its derived conversation key) so the next fetch picks up the new key.
const pubCache = new Map<number, string>();
export async function partnerPub(partnerId: number): Promise<string> {
const cached = pubCache.get(partnerId);
if (cached) return cached;
const r = await api.messagePublicKey(partnerId);
pubCache.set(partnerId, r.public_key);
return r.public_key;
}
// Drop a partner's cached public key + derived conversation key and refetch. Called after a decrypt
// fails, which can mean the partner reset their passphrase and published a NEW keypair; refetching
// lets this device both read their new messages and encrypt future ones under the current key.
//
// KNOWN LIMITATION: this refresh is wired only to the DECRYPT path. If you send a partner a message
// AFTER they rotated their key but BEFORE any of their messages arrived to trigger a refresh, that
// one send is encrypted under the stale cached key and is undecryptable for them (subsequent sends
// self-heal once an incoming message refreshes the cache). Accepted for now — same cross-user
// staleness envelope as the multi-device note in useKeyState; a fuller fix needs a key TTL / a
// send-time freshness check, which is out of the current no-forward-secrecy scope.
export async function refreshPartnerPub(partnerId: number): Promise<string> {
pubCache.delete(partnerId);
forgetConv(partnerId);
return partnerPub(partnerId);
}
// Live "is secure messaging unlocked on this device" — shared so the page and the dock agree.
function useUnlocked(): boolean {
return useSyncExternalStore(subscribeUnlock, isUnlocked, isUnlocked);
}
// The SERVER is the source of truth for whether messaging is set up. Combining it with the
// local unlock state gives the real gate: "setup" if the server has no key (even if a stale
// private key lingers in this browser — setup overwrites it), "unlock" if the server has a key
// but this device hasn't unlocked it, else "ready".
//
// KNOWN LIMITATION (multi-device, passphrase reset): this doesn't compare the locally-unlocked
// key's public half against the server's current public_key, so if the user resets their passphrase
// on ANOTHER device (new keypair), this already-unlocked device stays "ready" with the now-dead old
// key and silently fails to decrypt new messages until it's manually re-unlocked. Accepted for now:
// it fits the E2EE scope (static pairwise key, no forward secrecy) and self-heals once the device is
// re-unlocked with the new passphrase. Incoming-message decrypt failures on the CURRENT device DO
// self-heal via refreshPartnerPub() (a partner's rotation), which covers the common single-device case.
export type KeyState = "loading" | "setup" | "unlock" | "ready";
export function useKeyState(): KeyState {
const unlocked = useUnlocked();
const q = useQuery({ queryKey: qk.messageKey(), queryFn: api.messageMyKey, staleTime: 60_000 });
if (q.isLoading && !q.data) return "loading";
if (!(q.data?.configured ?? false)) return "setup";
if (!unlocked) return "unlock";
return "ready";
}
-9
View File
@@ -6,7 +6,6 @@ import {
Download, Download,
Home, Home,
ListVideo, ListVideo,
MessageSquare,
ScrollText, ScrollText,
FileSearch, FileSearch,
Settings, Settings,
@@ -90,14 +89,6 @@ const MODULES: readonly ModuleDef[] = [
icon: Bell, icon: Bell,
hasBadge: true, hasBadge: true,
}, },
{
id: "messages",
labelKey: "messages.navLabel",
railKey: "nav.rail.messages",
icon: MessageSquare,
hasBadge: true,
gate: notDemo,
},
{ {
id: "downloads", id: "downloads",
labelKey: "downloads.navLabel", labelKey: "downloads.navLabel",
@@ -34,7 +34,6 @@ describe("network and console boundary", () => {
it.each([ it.each([
["src/lib/http.ts", "fetch"], ["src/lib/http.ts", "fetch"],
["src/lib/api/core.ts", "XMLHttpRequest"], ["src/lib/api/core.ts", "XMLHttpRequest"],
["src/lib/messagesSocket.ts", "WebSocket"],
])("%s may use %s — and nothing else", async (owner, own) => { ])("%s may use %s — and nothing else", async (owner, own) => {
const found = await flagged(ALL, owner); const found = await flagged(ALL, owner);
// Its own global goes quiet; the other two globals, the two properties and console still fire. // Its own global goes quiet; the other two globals, the two properties and console still fire.
-1
View File
@@ -11,7 +11,6 @@ const EXPECTED: Record<Page, string> = {
channels: "header.channelManager", channels: "header.channelManager",
playlists: "header.account.playlists", playlists: "header.account.playlists",
notifications: "inbox.navLabel", notifications: "inbox.navLabel",
messages: "messages.navLabel",
downloads: "downloads.navLabel", downloads: "downloads.navLabel",
stats: "header.usageStats", stats: "header.usageStats",
plex: "plex.navLabel", plex: "plex.navLabel",
-3
View File
@@ -26,8 +26,6 @@ describe("qk — bare vs parameterized branch", () => {
expect(qk.playlist(7)).toEqual(["playlist", 7]); expect(qk.playlist(7)).toEqual(["playlist", 7]);
expect(qk.ytSearch(null, 3)).toEqual(["yt-search", null, 3]); expect(qk.ytSearch(null, 3)).toEqual(["yt-search", null, 3]);
expect(qk.ytSearch("cats", 3)).toEqual(["yt-search", "cats", 3]); expect(qk.ytSearch("cats", 3)).toEqual(["yt-search", "cats", 3]);
expect(qk.thread(null)).toEqual(["thread", null]);
expect(qk.thread(42)).toEqual(["thread", 42]);
}); });
}); });
@@ -50,7 +48,6 @@ describe("qk — single-id and scalar keys", () => {
expect(qk.myStatus()).toEqual(["my-status"]); expect(qk.myStatus()).toEqual(["my-status"]);
expect(qk.channels()).toEqual(["channels"]); expect(qk.channels()).toEqual(["channels"]);
expect(qk.playlists()).toEqual(["playlists"]); expect(qk.playlists()).toEqual(["playlists"]);
expect(qk.conversations()).toEqual(["conversations"]);
expect(qk.downloads()).toEqual(["downloads"]); expect(qk.downloads()).toEqual(["downloads"]);
expect(qk.adminUsers()).toEqual(["admin-users"]); expect(qk.adminUsers()).toEqual(["admin-users"]);
expect(qk.plex()).toEqual(["plex"]); expect(qk.plex()).toEqual(["plex"]);
+1 -7
View File
@@ -62,12 +62,7 @@ export const qk = {
notifications: () => ["notifications"] as const, notifications: () => ["notifications"] as const,
notifUnread: () => ["notif-unread"] as const, notifUnread: () => ["notif-unread"] as const,
// --- messaging --- // --- sharing ---
conversations: () => ["conversations"] as const,
thread: (partnerId: number | null) => ["thread", partnerId] as const,
messageKey: () => ["message-key"] as const,
messageUnread: () => ["message-unread"] as const,
messageUsers: () => ["message-users"] as const,
shareRecipients: () => ["share-recipients"] as const, shareRecipients: () => ["share-recipients"] as const,
// --- downloads --- // --- downloads ---
@@ -168,6 +163,5 @@ export const LANGUAGE_ORDERED = [
qk.discoveredChannels(), qk.discoveredChannels(),
qk.blockedChannels(), qk.blockedChannels(),
qk.shareRecipients(), qk.shareRecipients(),
qk.messageUsers(),
qk.plexCollections(), qk.plexCollections(),
] as const; ] as const;
@@ -52,7 +52,6 @@ const COMPLEX: Record<string, number> = {
"components/Home.tsx::Home": 34, "components/Home.tsx::Home": 34,
"components/Home.tsx::useHomeCardMenu": 21, "components/Home.tsx::useHomeCardMenu": 21,
"components/IssueComposer.tsx::ComposerBody": 34, "components/IssueComposer.tsx::ComposerBody": 34,
"components/KeyGate.tsx::KeyGate": 22,
"components/NotificationsPanel.tsx::NotificationRow": 28, "components/NotificationsPanel.tsx::NotificationRow": 28,
"components/OnboardingWizard.tsx::OnboardingWizard": 21, "components/OnboardingWizard.tsx::OnboardingWizard": 21,
"components/PersonalViewsModal.tsx::PersonalViewsModal": 22, "components/PersonalViewsModal.tsx::PersonalViewsModal": 22,
-1
View File
@@ -74,7 +74,6 @@ export const LS = {
// The public /watch page's repeat toggle (JSON boolean; on by default). Browser-wide, NOT per account: // The public /watch page's repeat toggle (JSON boolean; on by default). Browser-wide, NOT per account:
// that page runs outside the app, with no account at all. // that page runs outside the app, with no account at all.
watchRepeat: "siftlode.watchRepeat", watchRepeat: "siftlode.watchRepeat",
chatDock: (userId: number) => `siftlode.chatDock.${userId}`,
} as const; } as const;
// --- per-account scoping ----------------------------------------------------------------- // --- per-account scoping -----------------------------------------------------------------
-1
View File
@@ -131,7 +131,6 @@ const PAGES = [
"audit", "audit",
"logs", "logs",
"notifications", "notifications",
"messages",
"downloads", "downloads",
"plex", "plex",
] as const; ] as const;
+2 -1
View File
@@ -146,7 +146,8 @@ def _footprint(
patterns = { patterns = {
"demo_files": re.compile(r"\bdemo|isDemo|is_demo", re.IGNORECASE), "demo_files": re.compile(r"\bdemo|isDemo|is_demo", re.IGNORECASE),
"chat_files": re.compile(r"\bchat\b|\bmessages?\.py|\bChat[A-Z]|message_keys"), "chat_files": re.compile(r"\bchat\b|\bmessages?\.py|\bChat[A-Z]|message_keys"),
"access_request_files": re.compile(r"access[ _-]?request|AccessRequest", re.IGNORECASE), # the public form, not the admin's access-request queue (that stays)
"access_request_form_files": re.compile(r"request[-_]access|requestAccess|AccessRequestIn"),
} }
return { return {
k: sum(1 for _, ls in be + fe if any(rx.search(ln) for ln in ls)) k: sum(1 for _, ls in be + fe if any(rx.search(ln) for ln in ls))