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:
@@ -63,7 +63,7 @@ how it gets built. Concretely:
|
||||
|
||||
`routes/*` (HTTP only: validation, auth-dependency, serialization) → domain packages `sync/`, `plex/`,
|
||||
`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.**
|
||||
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
|
||||
|
||||
@@ -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": [
|
||||
{
|
||||
"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
|
||||
),
|
||||
)
|
||||
@@ -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.
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
+3
-4
@@ -278,8 +278,7 @@ ACTIVE_ACCOUNT_HEADER = "x-siftlode-account"
|
||||
|
||||
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.
|
||||
Takes any HTTPConnection — an HTTP Request OR a WebSocket — so the WS push channel shares this
|
||||
exact per-tab resolution instead of re-implementing it.
|
||||
Takes an HTTPConnection rather than a Request: it reads only the session and the headers.
|
||||
|
||||
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
|
||||
@@ -297,8 +296,8 @@ def resolved_user_id(conn: HTTPConnection, *, strict: bool = True) -> tuple[int
|
||||
"""
|
||||
default_id = conn.session.get("user_id")
|
||||
wallet = conn.session.get("account_ids") or []
|
||||
# Header for normal XHR; ?account= for contexts that can't set headers (WebSocket, and a
|
||||
# plain <a> file download). Both are wallet-gated below.
|
||||
# Header for normal XHR; ?account= for a context that can't set headers (a plain <a> file
|
||||
# download). Both are wallet-gated below.
|
||||
hdr = conn.headers.get(ACTIVE_ACCOUNT_HEADER) or conn.query_params.get("account")
|
||||
if hdr:
|
||||
try:
|
||||
|
||||
@@ -688,7 +688,6 @@ _POLLED_ROUTES = frozenset(
|
||||
{
|
||||
"/api/me",
|
||||
"/api/me/notifications/unread_count",
|
||||
"/api/messages/unread_count",
|
||||
"/api/downloads",
|
||||
"/api/downloads/index",
|
||||
"/api/downloads/usage",
|
||||
|
||||
@@ -44,7 +44,6 @@ from app.routes import ( # noqa: E402 — logging is configured first
|
||||
feed,
|
||||
health,
|
||||
me,
|
||||
messages,
|
||||
notifications,
|
||||
playlists,
|
||||
plex as plex_routes,
|
||||
@@ -311,7 +310,6 @@ app.include_router(me.router)
|
||||
app.include_router(diagnostics_routes.router)
|
||||
app.include_router(debug_sessions_routes.router)
|
||||
app.include_router(notifications.router)
|
||||
app.include_router(messages.router)
|
||||
app.include_router(channels.router)
|
||||
app.include_router(playlists.router)
|
||||
app.include_router(saved_views.router)
|
||||
|
||||
+1
-62
@@ -59,7 +59,7 @@ POSITION_MAX_S = 2**31 - 1
|
||||
|
||||
class TimestampMixin:
|
||||
"""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())
|
||||
|
||||
@@ -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):
|
||||
"""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
|
||||
|
||||
|
||||
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) -------------------------------------------------------------
|
||||
# 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
|
||||
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -11,7 +11,7 @@ Design:
|
||||
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.
|
||||
* 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
|
||||
yt-dlp download if it's no longer `running`.
|
||||
* Crash-safe: on startup, orphaned `running` jobs are requeued and `downloading` assets reset.
|
||||
|
||||
+1
-1
@@ -31,7 +31,7 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "E2EE material and cookies",
|
||||
"name": "ciphertext, passphrases and cookies",
|
||||
"key": "",
|
||||
"value": {
|
||||
"ciphertext": "AAAA",
|
||||
|
||||
@@ -221,30 +221,13 @@ def _others(db, n_offset: int = 0) -> list[User]:
|
||||
return users
|
||||
|
||||
|
||||
def test_the_share_and_message_recipients_are_in_the_readers_alphabet(
|
||||
db, monkeypatch, override_dep
|
||||
):
|
||||
from app.models import MessageKey
|
||||
|
||||
def test_the_share_recipients_are_in_the_readers_alphabet(db, monkeypatch, override_dep):
|
||||
user = _user(db, "hu")
|
||||
others = _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
|
||||
)
|
||||
_others(db)
|
||||
db.commit()
|
||||
client = _client(db, user, monkeypatch, override_dep)
|
||||
emails = [r["email"] for r in client.get("/api/downloads/recipients").json()]
|
||||
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):
|
||||
|
||||
@@ -219,11 +219,11 @@ def _client() -> TestClient:
|
||||
raise HTTPException(status_code=400, detail={"code": "bad_cursor"})
|
||||
return {"ok": True}
|
||||
|
||||
@app.get("/api/messages/unread_count")
|
||||
@app.get("/api/me/notifications/unread_count")
|
||||
def _unread():
|
||||
return {"count": 0}
|
||||
|
||||
@app.post("/api/messages/unread_count")
|
||||
@app.post("/api/me/notifications/unread_count")
|
||||
def _unread_write():
|
||||
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.setLevel(logs.TRACE)
|
||||
client = _client()
|
||||
r = client.get("/api/messages/unread_count")
|
||||
r = client.get("/api/me/notifications/unread_count")
|
||||
assert sink.objects()[-1]["level"] == "DEBUG"
|
||||
# ...and says so, for the browser's copy of the line (frontend lib/http.ts).
|
||||
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 "X-Log-Quiet" not in r.headers
|
||||
assert client.get("/api/videos/abc").status_code == 200
|
||||
|
||||
@@ -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."]
|
||||
@@ -23,11 +23,9 @@ COMMITTING_HELPERS = {
|
||||
"search.py:_ingest_candidates": "stages around YouTube calls: the stubs must survive an enrich "
|
||||
"that fails",
|
||||
"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 "
|
||||
"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 "
|
||||
"the handler is already unwinding and has no commit left to reach",
|
||||
}
|
||||
|
||||
@@ -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 |
|
||||
| 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/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 | `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. | — |
|
||||
|
||||
@@ -25,13 +25,13 @@ import playwright from "eslint-plugin-playwright";
|
||||
// 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
|
||||
// with a request id (lib/http.ts), the upload XHR (lib/api/core.ts), the messages socket
|
||||
// (lib/messagesSocket.ts). A bare `fetch` elsewhere is a request the client log never sees, which is
|
||||
// exactly how the baseline survey found four. Each owner is exempt from its OWN global only.
|
||||
// with a request id (lib/http.ts), the upload XHR (lib/api/core.ts). A bare `fetch` elsewhere is a
|
||||
// request the client log never sees, which is exactly how the baseline survey found four. Each owner
|
||||
// is exempt from its OWN global only; a global with no owner (null) has no exempt file at all.
|
||||
const NETWORK_OWNERS = {
|
||||
fetch: "src/lib/http.ts",
|
||||
XMLHttpRequest: "src/lib/api/core.ts",
|
||||
WebSocket: "src/lib/messagesSocket.ts",
|
||||
WebSocket: null,
|
||||
EventSource: null,
|
||||
};
|
||||
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
|
||||
// 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
|
||||
// unless asked to (`throwOnError`), so those calls are known-safe by name.
|
||||
const typedRules = {
|
||||
|
||||
@@ -35,7 +35,6 @@ import BackToTop from "./components/ui/BackToTop";
|
||||
import PageShell from "./components/ui/PageShell";
|
||||
import { SkipLink } from "./components/ui/SkipLink";
|
||||
import { pageTitleKey } from "./lib/pageMeta";
|
||||
import ChatDock from "./components/ChatDock";
|
||||
import DebugRecorder from "./components/DebugRecorder";
|
||||
import Toaster from "./components/ui/Toaster";
|
||||
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. */}
|
||||
<KeyboardHelp />
|
||||
<AppCommandPalette />
|
||||
{me && !me.is_demo && <ChatDock meId={me.id} />}
|
||||
{/* The debug recorder (E16): its start dialog, and its pill docked under the toasts. */}
|
||||
{me && !me.is_demo && <DebugRecorder />}
|
||||
<Suspense fallback={null}>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -7,7 +7,6 @@ import { Info, LogOut, Settings, Shield, UserPlus, type LucideIcon } from "lucid
|
||||
import { api, type Me } from "../lib/api";
|
||||
import { displayName, roleKey } from "../lib/api/account";
|
||||
import { pinOnNextPage } from "../lib/activeAccount";
|
||||
import * as e2ee from "../lib/e2ee";
|
||||
import { useLiveQuery } from "../lib/useLiveQuery";
|
||||
import { getUnreadCount, subscribe } from "../lib/notifications";
|
||||
import type { Page } from "../lib/urlState";
|
||||
@@ -84,14 +83,6 @@ export default function NavSidebar({
|
||||
} catch {
|
||||
/* 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);
|
||||
location.reload();
|
||||
}
|
||||
@@ -112,8 +103,7 @@ export default function NavSidebar({
|
||||
pinOnNextPage(id);
|
||||
// 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
|
||||
// stale sub-view — e.g. an open chat thread whose partnerId belongs to the OLD identity
|
||||
// (often the new account's own id → an empty self-thread). Start at the module root instead.
|
||||
// stale sub-view that belongs to the OLD identity. Start at the module root instead.
|
||||
const st = { ...(window.history.state || {}) };
|
||||
delete st._sub;
|
||||
delete st._ov;
|
||||
@@ -130,12 +120,6 @@ export default function NavSidebar({
|
||||
// events (the former separate bell is now folded into the inbox page).
|
||||
const clientUnread = useSyncExternalStore(subscribe, getUnreadCount, getUnreadCount);
|
||||
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
|
||||
// lightweight per-user index the feed cards poll, so no extra request shape.
|
||||
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).
|
||||
const BADGE: Partial<Record<Page, number>> = {
|
||||
notifications: unread,
|
||||
messages: msgUnread,
|
||||
downloads: dlActive,
|
||||
};
|
||||
const META = (p: Page): Omit<NavItem, "page"> => ({
|
||||
|
||||
@@ -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 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
|
||||
// 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
|
||||
|
||||
@@ -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
|
||||
// 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
|
||||
// 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.
|
||||
const [pickSeq, setPickSeq] = useState(0);
|
||||
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
|
||||
// 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 { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
@@ -24,16 +23,12 @@ vi.mock("../lib/api", async (importOriginal) => {
|
||||
api: {
|
||||
...actual.api,
|
||||
authConfig: () => Promise.resolve({ google_enabled: false }),
|
||||
messageMyKey: () => Promise.resolve({ configured: true }),
|
||||
me: () => Promise.resolve({ authenticated: 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 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", () => {
|
||||
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 () => {
|
||||
render(
|
||||
<Welcome verify={{ outcome: null, email: null, resetPending: false, dismiss: () => {} }} />,
|
||||
|
||||
@@ -16,7 +16,7 @@ import LanguageSwitcher from "../LanguageSwitcher";
|
||||
import { SURFACE } from "../ui/surfaces";
|
||||
|
||||
// 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
|
||||
// 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
|
||||
detailed record of your requests, for the same retention period as the server logs;
|
||||
</li>
|
||||
<li>
|
||||
passwords, tokens, email addresses and the content of your messages are never included, at
|
||||
any level.
|
||||
</li>
|
||||
<li>passwords, tokens and email addresses are never included, at any level.</li>
|
||||
</ul>
|
||||
<p>
|
||||
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 & security</H2>
|
||||
<p>
|
||||
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
|
||||
users are end-to-end encrypted in the browser: the server stores only the encrypted text and
|
||||
cannot read it. Access is restricted to the email addresses admitted to the app. No system
|
||||
is perfectly secure, but we take reasonable measures to protect your data.
|
||||
the operator. Google OAuth refresh tokens are encrypted at rest. Access is restricted to the
|
||||
email addresses admitted to the app. No system is perfectly secure, but we take reasonable
|
||||
measures to protect your data.
|
||||
</p>
|
||||
|
||||
<H2 id="retention">Retention & 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
|
||||
szerveroldali naplójával együtt, a szervernapló megőrzési idejéig;
|
||||
</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>
|
||||
<p>
|
||||
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>
|
||||
<p>
|
||||
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
|
||||
felhasználók közötti privát üzenetek végponttól végpontig titkosítottak a böngészőben: a
|
||||
szerver csak a titkosított szöveget tárolja, és nem tudja elolvasni. Csak az alkalmazásba
|
||||
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.
|
||||
csak az üzemeltető fér hozzá. A Google OAuth frissítő tokeneket titkosítva tároljuk. Csak az
|
||||
alkalmazásba 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>
|
||||
|
||||
<H2 id="retention">Megőrzés és törlés</H2>
|
||||
|
||||
@@ -10,7 +10,6 @@ import { useSavedViews } from "./savedViewsContext";
|
||||
// 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.
|
||||
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
|
||||
// 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { Page } from "../lib/urlState";
|
||||
import type { PanelId, PanelLayout } from "../lib/panelLayout";
|
||||
import Sidebar from "./Sidebar";
|
||||
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
|
||||
// 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" },
|
||||
plex: { Component: PlexBrowse, maxWidth: "100rem" },
|
||||
notifications: { Component: NotificationsPanel, maxWidth: "48rem" },
|
||||
messages: { Component: Object.assign(MessagesPage, { preload: Messages.preload }) },
|
||||
downloads: { Component: DownloadCenter, maxWidth: "56rem" },
|
||||
stats: { Component: Stats },
|
||||
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", () => {
|
||||
// The instance is not remounted here on purpose: the player's channel avatar and the Messages
|
||||
// thread header both swap `src` on a live instance. A memory that outlived the reason for it
|
||||
// The instance is not remounted here on purpose: the player's channel avatar swaps `src` on a
|
||||
// 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.
|
||||
const { rerender } = render(
|
||||
<Avatar src="https://cdn.test/a.jpg" alt="avatar" fallback="Zed" />,
|
||||
|
||||
@@ -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
|
||||
// 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
|
||||
// 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
|
||||
|
||||
@@ -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
|
||||
// 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").
|
||||
aria-label={`${showLabel ? shortLabel : label}${shown ? ` (${badge})` : ""}`}
|
||||
aria-current={active ? "page" : undefined}
|
||||
|
||||
@@ -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`
|
||||
/ `FilterMap` shape and `filterRows` are `lib/tableColumns`, which `tableHeader` and `DataTable` import.
|
||||
|
||||
Counter-examples, deliberately NOT here: `PosterTile` (knows about Plex montage posters), `KeyGate`
|
||||
(secure-messaging key setup), `plexCards`, `downloadRow`, `channelColumns` — all shared, none
|
||||
domain-free.
|
||||
Counter-examples, deliberately NOT here: `PosterTile` (knows about Plex montage posters),
|
||||
`plexCards`, `downloadRow`, `channelColumns` — all shared, none domain-free.
|
||||
|
||||
## What lives here
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
Home,
|
||||
ListVideo,
|
||||
Lock,
|
||||
MessageSquare,
|
||||
PlayCircle,
|
||||
Repeat,
|
||||
Settings,
|
||||
@@ -290,7 +289,6 @@ const NAV: NavDef[] = [
|
||||
{ icon: ListVideo, en: "Playlists", hu: "Listák" },
|
||||
{ icon: Clapperboard, en: "Plex", hu: "Plex" },
|
||||
{ icon: Bell, en: "Alerts", hu: "Értesítés" },
|
||||
{ icon: MessageSquare, en: "Messages", hu: "Üzenetek" },
|
||||
{ icon: Download, en: "Downloads", hu: "Letöltések" },
|
||||
];
|
||||
const SYSTEM: NavDef[] = [
|
||||
|
||||
@@ -155,14 +155,8 @@
|
||||
"link_gone": "This link is no longer available",
|
||||
"link_required": "A video link or id is required",
|
||||
"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",
|
||||
"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_subscription_id": "There's no YouTube subscription on record yet — sync your subscriptions first",
|
||||
"not_authenticated": "You're not signed in",
|
||||
|
||||
@@ -27,7 +27,6 @@
|
||||
"goPlaylists": "Go to Playlists",
|
||||
"goPlex": "Go to Plex",
|
||||
"goNotifications": "Go to Notifications",
|
||||
"goMessages": "Go to Messages",
|
||||
"goDownloads": "Go to Downloads",
|
||||
"goStats": "Go to Stats",
|
||||
"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."
|
||||
}
|
||||
@@ -14,7 +14,6 @@
|
||||
"playlists": "Playlists",
|
||||
"plex": "Plex",
|
||||
"notifications": "Notifications",
|
||||
"messages": "Messages",
|
||||
"downloads": "Downloads",
|
||||
"stats": "Stats",
|
||||
"scheduler": "Scheduler",
|
||||
|
||||
@@ -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_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.",
|
||||
"policy": "Privacy policy",
|
||||
"cancel": "Cancel",
|
||||
|
||||
@@ -155,14 +155,8 @@
|
||||
"link_gone": "Ez a link már nem érhető el",
|
||||
"link_required": "Adj meg egy videólinket vagy -azonosítót",
|
||||
"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",
|
||||
"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_subscription_id": "Még nincs rögzített YouTube-feliratkozás — előbb szinkronizáld a feliratkozásaidat",
|
||||
"not_authenticated": "Nem vagy bejelentkezve",
|
||||
|
||||
@@ -27,7 +27,6 @@
|
||||
"goPlaylists": "Ugrás: Lejátszási listák",
|
||||
"goPlex": "Ugrás: Plex",
|
||||
"goNotifications": "Ugrás: Értesítések",
|
||||
"goMessages": "Ugrás: Üzenetek",
|
||||
"goDownloads": "Ugrás: Letöltések",
|
||||
"goStats": "Ugrás: Statisztika",
|
||||
"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."
|
||||
}
|
||||
@@ -14,7 +14,6 @@
|
||||
"playlists": "Listák",
|
||||
"plex": "Plex",
|
||||
"notifications": "Értesítések",
|
||||
"messages": "Üzenetek",
|
||||
"downloads": "Letöltések",
|
||||
"stats": "Statisztika",
|
||||
"scheduler": "Ütemező",
|
||||
|
||||
@@ -220,7 +220,7 @@
|
||||
},
|
||||
"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.",
|
||||
"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.",
|
||||
"policy": "Adatvédelmi irányelvek",
|
||||
"cancel": "Mégse",
|
||||
|
||||
@@ -972,32 +972,6 @@ html[data-perf="1"] .overlay-backdrop {
|
||||
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 ===== */
|
||||
|
||||
|
||||
@@ -58,8 +58,6 @@ export {
|
||||
parsePlexFilters,
|
||||
isIntrinsicReadOnly,
|
||||
} from "./api/plex";
|
||||
import { messagingApi } from "./api/messaging";
|
||||
export type * from "./api/messaging";
|
||||
import { downloadsApi } from "./api/downloads";
|
||||
export type * from "./api/downloads";
|
||||
import { debugSessionsApi, diagnosticsApi } from "./api/diagnostics";
|
||||
@@ -81,7 +79,6 @@ export const api = {
|
||||
...quotaApi,
|
||||
...adminApi,
|
||||
...plexApi,
|
||||
...messagingApi,
|
||||
...downloadsApi,
|
||||
...diagnosticsApi,
|
||||
...debugSessionsApi,
|
||||
|
||||
@@ -30,7 +30,6 @@ const SLICE_MODULES = [
|
||||
["quotaApi", () => import("./quota")],
|
||||
["adminApi", () => import("./admin")],
|
||||
["plexApi", () => import("./plex")],
|
||||
["messagingApi", () => import("./messaging")],
|
||||
["downloadsApi", () => import("./downloads")],
|
||||
["diagnosticsApi", () => import("./diagnostics")],
|
||||
["debugSessionsApi", () => import("./diagnostics")],
|
||||
|
||||
@@ -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"),
|
||||
};
|
||||
@@ -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 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -270,7 +270,7 @@
|
||||
"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"
|
||||
},
|
||||
{
|
||||
@@ -338,7 +338,7 @@
|
||||
"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"
|
||||
},
|
||||
{
|
||||
@@ -386,7 +386,7 @@
|
||||
"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"
|
||||
},
|
||||
{
|
||||
@@ -408,54 +408,6 @@
|
||||
},
|
||||
"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": {
|
||||
"languageOptions": {
|
||||
"ecmaVersion": 2022,
|
||||
@@ -852,7 +804,7 @@
|
||||
"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"
|
||||
},
|
||||
{
|
||||
@@ -1159,7 +1111,7 @@
|
||||
"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"
|
||||
},
|
||||
{
|
||||
@@ -1362,7 +1314,7 @@
|
||||
"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"
|
||||
},
|
||||
{
|
||||
@@ -1465,7 +1417,6 @@
|
||||
"src/lib/channelOrder.dom.test.ts",
|
||||
"src/lib/channelWriteFailures.dom.test.tsx",
|
||||
"src/lib/channelsUrlState.dom.test.ts",
|
||||
"src/lib/chatDock.dom.test.ts",
|
||||
"src/lib/chipLane.contract.test.ts",
|
||||
"src/lib/chipOverflow.test.ts",
|
||||
"src/lib/columnSort.test.ts",
|
||||
@@ -1478,7 +1429,6 @@
|
||||
"src/lib/downloadActions.test.ts",
|
||||
"src/lib/downloadFailure.test.ts",
|
||||
"src/lib/downloads.test.ts",
|
||||
"src/lib/e2ee.test.ts",
|
||||
"src/lib/fadeEdge.contract.test.ts",
|
||||
"src/lib/feedSort.yt.test.ts",
|
||||
"src/lib/focusSafe.contract.test.ts",
|
||||
@@ -1512,7 +1462,6 @@
|
||||
"src/lib/log.test.ts",
|
||||
"src/lib/logHooks.dom.test.ts",
|
||||
"src/lib/logsUrlState.test.ts",
|
||||
"src/lib/messagesSocket.test.ts",
|
||||
"src/lib/moveInList.test.ts",
|
||||
"src/lib/mutationFailures.contract.test.ts",
|
||||
"src/lib/navPosition.wiring.contract.test.ts",
|
||||
@@ -1588,7 +1537,6 @@
|
||||
],
|
||||
"lib/api/core.ts": ["src/lib/api/core.ts"],
|
||||
"lib/http.ts": ["src/lib/http.ts"],
|
||||
"lib/messagesSocket.ts": ["src/lib/messagesSocket.ts"],
|
||||
"src": [
|
||||
"src/App.tsx",
|
||||
"src/components/About.tsx",
|
||||
@@ -1613,8 +1561,6 @@
|
||||
"src/components/ChannelTags.tsx",
|
||||
"src/components/Channels.tsx",
|
||||
"src/components/ChannelsProvider.tsx",
|
||||
"src/components/ChatDock.tsx",
|
||||
"src/components/ChatThread.tsx",
|
||||
"src/components/ConfigPanel.tsx",
|
||||
"src/components/ConfirmProvider.tsx",
|
||||
"src/components/DateFilterSelect.tsx",
|
||||
@@ -1635,12 +1581,10 @@
|
||||
"src/components/Header.tsx",
|
||||
"src/components/Home.tsx",
|
||||
"src/components/IssueComposer.tsx",
|
||||
"src/components/KeyGate.tsx",
|
||||
"src/components/LanguageSwitcher.tsx",
|
||||
"src/components/LibraryPlayer.tsx",
|
||||
"src/components/LogLine.tsx",
|
||||
"src/components/MediaResumeButtons.tsx",
|
||||
"src/components/Messages.tsx",
|
||||
"src/components/MyLogViewer.tsx",
|
||||
"src/components/NavSidebar.tsx",
|
||||
"src/components/NavigationProvider.tsx",
|
||||
@@ -1800,7 +1744,6 @@
|
||||
"src/components/Home.pin.dom.test.tsx",
|
||||
"src/components/IssueComposer.dom.test.tsx",
|
||||
"src/components/LibraryPlayer.dom.test.tsx",
|
||||
"src/components/Messages.keyboard.dom.test.tsx",
|
||||
"src/components/MyLogViewer.dom.test.tsx",
|
||||
"src/components/NavigationProvider.dom.test.tsx",
|
||||
"src/components/NotificationsPanel.menu.dom.test.tsx",
|
||||
@@ -1914,7 +1857,6 @@
|
||||
"src/lib/api/discover.ts",
|
||||
"src/lib/api/downloads.ts",
|
||||
"src/lib/api/feed.ts",
|
||||
"src/lib/api/messaging.ts",
|
||||
"src/lib/api/notifications.ts",
|
||||
"src/lib/api/playlists.ts",
|
||||
"src/lib/api/plex.ts",
|
||||
@@ -1933,7 +1875,6 @@
|
||||
"src/lib/channelLayout.ts",
|
||||
"src/lib/channelOrder.ts",
|
||||
"src/lib/channelsUrlState.ts",
|
||||
"src/lib/chatDock.ts",
|
||||
"src/lib/chipOverflow.ts",
|
||||
"src/lib/clipboard.ts",
|
||||
"src/lib/collate.ts",
|
||||
@@ -1947,7 +1888,6 @@
|
||||
"src/lib/downloadActions.ts",
|
||||
"src/lib/downloadFailure.ts",
|
||||
"src/lib/downloads.ts",
|
||||
"src/lib/e2ee.ts",
|
||||
"src/lib/entryUrl.ts",
|
||||
"src/lib/errorDialog.ts",
|
||||
"src/lib/feedSort.ts",
|
||||
@@ -1973,7 +1913,6 @@
|
||||
"src/lib/logsUrlState.ts",
|
||||
"src/lib/mediaLog.ts",
|
||||
"src/lib/mediaSource.ts",
|
||||
"src/lib/messaging.ts",
|
||||
"src/lib/modules.ts",
|
||||
"src/lib/moveInList.ts",
|
||||
"src/lib/nav.ts",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//
|
||||
// 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
|
||||
// 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
|
||||
// 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.
|
||||
|
||||
@@ -116,7 +116,6 @@ export const SHORTCUTS = [
|
||||
labelKey: "keys.goNotifications",
|
||||
page: "notifications",
|
||||
},
|
||||
{ id: "goMessages", scope: "page", keys: ["g m"], labelKey: "keys.goMessages", page: "messages" },
|
||||
{
|
||||
id: "goDownloads",
|
||||
scope: "page",
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
// 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`).
|
||||
// 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
|
||||
// call site asking `enabled(level)` before it builds the attribute.
|
||||
//
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
Download,
|
||||
Home,
|
||||
ListVideo,
|
||||
MessageSquare,
|
||||
ScrollText,
|
||||
FileSearch,
|
||||
Settings,
|
||||
@@ -90,14 +89,6 @@ const MODULES: readonly ModuleDef[] = [
|
||||
icon: Bell,
|
||||
hasBadge: true,
|
||||
},
|
||||
{
|
||||
id: "messages",
|
||||
labelKey: "messages.navLabel",
|
||||
railKey: "nav.rail.messages",
|
||||
icon: MessageSquare,
|
||||
hasBadge: true,
|
||||
gate: notDemo,
|
||||
},
|
||||
{
|
||||
id: "downloads",
|
||||
labelKey: "downloads.navLabel",
|
||||
|
||||
@@ -34,7 +34,6 @@ describe("network and console boundary", () => {
|
||||
it.each([
|
||||
["src/lib/http.ts", "fetch"],
|
||||
["src/lib/api/core.ts", "XMLHttpRequest"],
|
||||
["src/lib/messagesSocket.ts", "WebSocket"],
|
||||
])("%s may use %s — and nothing else", async (owner, own) => {
|
||||
const found = await flagged(ALL, owner);
|
||||
// Its own global goes quiet; the other two globals, the two properties and console still fire.
|
||||
|
||||
@@ -11,7 +11,6 @@ const EXPECTED: Record<Page, string> = {
|
||||
channels: "header.channelManager",
|
||||
playlists: "header.account.playlists",
|
||||
notifications: "inbox.navLabel",
|
||||
messages: "messages.navLabel",
|
||||
downloads: "downloads.navLabel",
|
||||
stats: "header.usageStats",
|
||||
plex: "plex.navLabel",
|
||||
|
||||
@@ -26,8 +26,6 @@ describe("qk — bare vs parameterized branch", () => {
|
||||
expect(qk.playlist(7)).toEqual(["playlist", 7]);
|
||||
expect(qk.ytSearch(null, 3)).toEqual(["yt-search", null, 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.channels()).toEqual(["channels"]);
|
||||
expect(qk.playlists()).toEqual(["playlists"]);
|
||||
expect(qk.conversations()).toEqual(["conversations"]);
|
||||
expect(qk.downloads()).toEqual(["downloads"]);
|
||||
expect(qk.adminUsers()).toEqual(["admin-users"]);
|
||||
expect(qk.plex()).toEqual(["plex"]);
|
||||
|
||||
@@ -62,12 +62,7 @@ export const qk = {
|
||||
notifications: () => ["notifications"] as const,
|
||||
notifUnread: () => ["notif-unread"] as const,
|
||||
|
||||
// --- messaging ---
|
||||
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,
|
||||
// --- sharing ---
|
||||
shareRecipients: () => ["share-recipients"] as const,
|
||||
|
||||
// --- downloads ---
|
||||
@@ -168,6 +163,5 @@ export const LANGUAGE_ORDERED = [
|
||||
qk.discoveredChannels(),
|
||||
qk.blockedChannels(),
|
||||
qk.shareRecipients(),
|
||||
qk.messageUsers(),
|
||||
qk.plexCollections(),
|
||||
] as const;
|
||||
|
||||
@@ -52,7 +52,6 @@ const COMPLEX: Record<string, number> = {
|
||||
"components/Home.tsx::Home": 34,
|
||||
"components/Home.tsx::useHomeCardMenu": 21,
|
||||
"components/IssueComposer.tsx::ComposerBody": 34,
|
||||
"components/KeyGate.tsx::KeyGate": 22,
|
||||
"components/NotificationsPanel.tsx::NotificationRow": 28,
|
||||
"components/OnboardingWizard.tsx::OnboardingWizard": 21,
|
||||
"components/PersonalViewsModal.tsx::PersonalViewsModal": 22,
|
||||
|
||||
@@ -74,7 +74,6 @@ export const LS = {
|
||||
// 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.
|
||||
watchRepeat: "siftlode.watchRepeat",
|
||||
chatDock: (userId: number) => `siftlode.chatDock.${userId}`,
|
||||
} as const;
|
||||
|
||||
// --- per-account scoping -----------------------------------------------------------------
|
||||
|
||||
@@ -131,7 +131,6 @@ const PAGES = [
|
||||
"audit",
|
||||
"logs",
|
||||
"notifications",
|
||||
"messages",
|
||||
"downloads",
|
||||
"plex",
|
||||
] as const;
|
||||
|
||||
@@ -146,7 +146,8 @@ def _footprint(
|
||||
patterns = {
|
||||
"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"),
|
||||
"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 {
|
||||
k: sum(1 for _, ls in be + fe if any(rx.search(ln) for ln in ls))
|
||||
|
||||
Reference in New Issue
Block a user