Merge feature/user-messaging — E2EE real-time direct messaging (epic 8 P2)
This commit is contained in:
commit
da37eb6344
26 changed files with 1834 additions and 1 deletions
56
backend/alembic/versions/0026_messages.py
Normal file
56
backend/alembic/versions/0026_messages.py
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
"""user-to-user messages
|
||||
|
||||
Revision ID: 0026_messages
|
||||
Revises: 0025_install_wizard
|
||||
Create Date: 2026-06-25
|
||||
|
||||
Adds the `messages` table for direct user-to-user messaging (notification module phase 2).
|
||||
A conversation is the set of rows between two users; both FKs cascade so a GDPR account
|
||||
erasure removes that user's messages.
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0026_messages"
|
||||
down_revision: Union[str, None] = "0025_install_wizard"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> 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=False,
|
||||
),
|
||||
sa.Column(
|
||||
"recipient_id",
|
||||
sa.Integer(),
|
||||
sa.ForeignKey("users.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("body", sa.Text(), nullable=False),
|
||||
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,
|
||||
),
|
||||
)
|
||||
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"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
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")
|
||||
68
backend/alembic/versions/0027_message_e2ee.py
Normal file
68
backend/alembic/versions/0027_message_e2ee.py
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
"""end-to-end encrypted messages + system messages
|
||||
|
||||
Revision ID: 0027_message_e2ee
|
||||
Revises: 0026_messages
|
||||
Create Date: 2026-06-25
|
||||
|
||||
Reworks direct messaging for end-to-end encryption. The `messages` table now stores ciphertext
|
||||
(+ iv) for private user-to-user messages instead of plaintext `body`; `body` is kept (nullable)
|
||||
for server-authored `kind="system"` messages (welcome / announcements). Adds `message_keys` to
|
||||
hold each user's public key + passphrase-wrapped private key (the server can never decrypt).
|
||||
|
||||
Any pre-existing rows were plaintext with no keys, so they're dropped (the feature is unshipped;
|
||||
prod has none).
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0027_message_e2ee"
|
||||
down_revision: Union[str, None] = "0026_messages"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Old rows were plaintext with no E2EE keys — can't be migrated; drop them.
|
||||
op.execute("DELETE FROM messages")
|
||||
op.add_column(
|
||||
"messages",
|
||||
sa.Column("kind", sa.String(length=16), nullable=False, server_default="user"),
|
||||
)
|
||||
op.add_column("messages", sa.Column("ciphertext", sa.Text(), nullable=True))
|
||||
op.add_column("messages", sa.Column("iv", sa.String(length=32), nullable=True))
|
||||
# System messages have no human sender and carry plaintext `body`.
|
||||
op.alter_column("messages", "sender_id", existing_type=sa.Integer(), nullable=True)
|
||||
op.alter_column("messages", "body", existing_type=sa.Text(), nullable=True)
|
||||
|
||||
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,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("message_keys")
|
||||
op.execute("DELETE FROM messages")
|
||||
op.alter_column("messages", "body", existing_type=sa.Text(), nullable=False)
|
||||
op.alter_column("messages", "sender_id", existing_type=sa.Integer(), nullable=False)
|
||||
op.drop_column("messages", "iv")
|
||||
op.drop_column("messages", "ciphertext")
|
||||
op.drop_column("messages", "kind")
|
||||
|
|
@ -34,6 +34,7 @@ from app.routes import (
|
|||
feed,
|
||||
health,
|
||||
me,
|
||||
messages,
|
||||
notifications,
|
||||
playlists,
|
||||
quota,
|
||||
|
|
@ -114,6 +115,7 @@ app.include_router(tags.router)
|
|||
app.include_router(feed.router)
|
||||
app.include_router(me.router)
|
||||
app.include_router(notifications.router)
|
||||
app.include_router(messages.router)
|
||||
app.include_router(channels.router)
|
||||
app.include_router(playlists.router)
|
||||
app.include_router(admin.router)
|
||||
|
|
|
|||
|
|
@ -518,3 +518,69 @@ class Notification(Base):
|
|||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), index=True
|
||||
)
|
||||
|
||||
|
||||
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 MessageKey(Base):
|
||||
"""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)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now()
|
||||
)
|
||||
|
|
|
|||
60
backend/app/realtime.py
Normal file
60
backend/app/realtime.py
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
"""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
|
||||
import logging
|
||||
from collections import defaultdict
|
||||
|
||||
from starlette.websockets import WebSocket
|
||||
|
||||
log = logging.getLogger("siftlode.realtime")
|
||||
|
||||
|
||||
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:
|
||||
# Stale socket; it'll be cleaned up by its own disconnect handler.
|
||||
pass
|
||||
|
||||
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:
|
||||
log.debug("ws push failed", exc_info=True)
|
||||
|
||||
|
||||
manager = ConnectionManager()
|
||||
380
backend/app/routes/messages.py
Normal file
380
backend/app/routes/messages.py
Normal file
|
|
@ -0,0 +1,380 @@
|
|||
"""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, HTTPException, WebSocket, WebSocketDisconnect
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import and_, func, or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.auth import require_human
|
||||
from app.db import SessionLocal, get_db
|
||||
from app.models import Message, MessageKey, User
|
||||
from app.ratelimit import RateLimiter
|
||||
from app.realtime import manager
|
||||
|
||||
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)
|
||||
|
||||
# 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!"
|
||||
),
|
||||
"de": (
|
||||
"Willkommen bei Siftlode! 👋\n\n"
|
||||
"Das ist dein privater Nachrichten-Posteingang. Unterhaltungen mit anderen Mitgliedern "
|
||||
"sind Ende-zu-Ende-verschlüsselt — nur du und dein Gegenüber könnt sie lesen, nicht "
|
||||
"einmal ein Admin. Um jemandem zu schreiben, richte zuerst die sichere "
|
||||
"Nachrichtenübermittlung mit einem Passwort ein.\n\n"
|
||||
"Viel Spaß mit deinem Feed!"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
class KeySetupIn(BaseModel):
|
||||
public_key: str
|
||||
wrapped_private_key: str
|
||||
salt: str
|
||||
wrap_iv: str
|
||||
key_check: str
|
||||
|
||||
|
||||
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}
|
||||
|
||||
|
||||
def _serialize_msg(m: Message) -> dict:
|
||||
return {
|
||||
"id": m.id,
|
||||
"kind": m.kind,
|
||||
"sender_id": m.sender_id,
|
||||
"recipient_id": m.recipient_id,
|
||||
"body": m.body, # only set for system messages
|
||||
"ciphertext": m.ciphertext,
|
||||
"iv": m.iv,
|
||||
"read_at": m.read_at.isoformat() if m.read_at else None,
|
||||
"created_at": m.created_at.isoformat() if m.created_at else None,
|
||||
}
|
||||
|
||||
|
||||
def _messageable() -> list:
|
||||
return [
|
||||
User.is_demo.is_(False),
|
||||
User.is_active.is_(True),
|
||||
User.is_suspended.is_(False),
|
||||
]
|
||||
|
||||
|
||||
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)})
|
||||
|
||||
|
||||
# --- 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 HTTPException(status_code=409, detail="Secure messaging is already 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 {"configured": True}
|
||||
|
||||
|
||||
@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."""
|
||||
k = db.get(MessageKey, user_id)
|
||||
if k is None:
|
||||
raise HTTPException(status_code=404, detail="That member hasn't set up messaging yet.")
|
||||
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())
|
||||
.order_by(func.lower(func.coalesce(User.display_name, User.email)))
|
||||
)
|
||||
.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)
|
||||
msgs = (
|
||||
db.execute(
|
||||
select(Message)
|
||||
.where(or_(Message.sender_id == user.id, Message.recipient_id == user.id))
|
||||
.order_by(Message.created_at.desc())
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
convs: dict[int, dict] = {}
|
||||
for m in msgs:
|
||||
if m.kind == "system":
|
||||
pid = SYSTEM_PARTNER_ID
|
||||
else:
|
||||
pid = m.recipient_id if m.sender_id == user.id else m.sender_id
|
||||
c = convs.setdefault(pid, {"last": m, "unread": 0})
|
||||
if m.recipient_id == user.id and m.read_at is None:
|
||||
c["unread"] += 1
|
||||
user_ids = [pid for pid in convs if pid != SYSTEM_PARTNER_ID]
|
||||
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 pid, c in convs.items():
|
||||
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(c["last"]), "unread": c["unread"]}
|
||||
)
|
||||
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)
|
||||
if p is None:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
partner = _serialize_user(p)
|
||||
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),
|
||||
),
|
||||
)
|
||||
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()
|
||||
return {"partner": partner, "items": [_serialize_msg(m) 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 HTTPException(status_code=429, detail="Too many messages — slow down a moment.")
|
||||
ct = (payload.ciphertext or "").strip()
|
||||
iv = (payload.iv or "").strip()
|
||||
if not ct or not iv:
|
||||
raise HTTPException(status_code=400, detail="Message is empty.")
|
||||
if len(ct) > MAX_CIPHERTEXT:
|
||||
raise HTTPException(status_code=400, detail="Message is too long.")
|
||||
if payload.recipient_id == user.id:
|
||||
raise HTTPException(status_code=400, detail="You can't message yourself.")
|
||||
recipient = db.get(User, payload.recipient_id)
|
||||
if recipient is None or recipient.is_demo or not recipient.is_active or recipient.is_suspended:
|
||||
raise HTTPException(status_code=404, detail="Recipient not available.")
|
||||
if db.get(MessageKey, recipient.id) is None:
|
||||
raise HTTPException(status_code=404, detail="That member hasn't set up messaging yet.")
|
||||
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."""
|
||||
uid = ws.session.get("user_id") if "session" in ws.scope else None
|
||||
if not uid:
|
||||
await ws.close(code=1008)
|
||||
return
|
||||
db = SessionLocal()
|
||||
try:
|
||||
u = db.get(User, uid)
|
||||
ok = bool(u and not u.is_demo and not u.is_suspended and u.is_active)
|
||||
finally:
|
||||
db.close()
|
||||
if not ok:
|
||||
await ws.close(code=1008)
|
||||
return
|
||||
await manager.connect(uid, ws)
|
||||
try:
|
||||
while True:
|
||||
await ws.receive_text()
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
finally:
|
||||
await manager.disconnect(uid, ws)
|
||||
|
|
@ -35,6 +35,8 @@ import ConfigPanel from "./components/ConfigPanel";
|
|||
import AdminUsers from "./components/AdminUsers";
|
||||
import SettingsPanel from "./components/SettingsPanel";
|
||||
import NotificationsPanel from "./components/NotificationsPanel";
|
||||
import Messages from "./components/Messages";
|
||||
import ChatDock from "./components/ChatDock";
|
||||
import OnboardingWizard from "./components/OnboardingWizard";
|
||||
import { shouldAutoOpenOnboarding } from "./lib/onboarding";
|
||||
import Toaster from "./components/Toaster";
|
||||
|
|
@ -546,6 +548,10 @@ export default function App() {
|
|||
<Playlists canWrite={meQuery.data!.can_write} />
|
||||
) : page === "notifications" ? (
|
||||
<NotificationsPanel filters={filters} setFilters={setFilters} setPage={setPage} onFocusChannel={focusChannel} />
|
||||
) : page === "messages" && !meQuery.data!.is_demo ? (
|
||||
<div className="p-4 max-w-3xl w-full mx-auto">
|
||||
<Messages meId={meQuery.data!.id} />
|
||||
</div>
|
||||
) : page === "settings" ? (
|
||||
<SettingsPanel
|
||||
me={meQuery.data!}
|
||||
|
|
@ -569,6 +575,7 @@ export default function App() {
|
|||
(collapsed or expanded) without tracking its width. */}
|
||||
<Toaster />
|
||||
</div>
|
||||
{meQuery.data && !meQuery.data.is_demo && <ChatDock meId={meQuery.data.id} />}
|
||||
{wizardOpen && meQuery.data && !meQuery.data.is_demo && (
|
||||
<OnboardingWizard me={meQuery.data} onClose={() => setWizardOpen(false)} />
|
||||
)}
|
||||
|
|
|
|||
91
frontend/src/components/ChatDock.tsx
Normal file
91
frontend/src/components/ChatDock.tsx
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
import { useEffect } from "react";
|
||||
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 } from "../lib/messagesSocket";
|
||||
import * as e2ee from "../lib/e2ee";
|
||||
import Avatar from "./Avatar";
|
||||
import ChatThread from "./ChatThread";
|
||||
|
||||
// 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);
|
||||
e2ee.loadFromDevice(meId);
|
||||
}, [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: ["conversations"] });
|
||||
qc.invalidateQueries({ queryKey: ["message-unread"] });
|
||||
const partnerId = m.sender_id === meId ? m.recipient_id : m.sender_id;
|
||||
qc.invalidateQueries({ queryKey: ["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]
|
||||
);
|
||||
|
||||
if (chats.length === 0) return null;
|
||||
return (
|
||||
<div className="fixed bottom-0 right-0 z-40 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>
|
||||
);
|
||||
}
|
||||
|
||||
function ChatWindow({ chat, meId }: { chat: DockChat; meId: number }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="pointer-events-auto relative w-80 max-w-[calc(100vw-2rem)] glass rounded-2xl shadow-2xl 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-10" />
|
||||
)}
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-border bg-card/50">
|
||||
<button
|
||||
onClick={() => toggleMinimize(chat.partnerId)}
|
||||
className="flex items-center gap-2 min-w-0 flex-1 text-left"
|
||||
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>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => toggleMinimize(chat.partnerId)}
|
||||
className="shrink-0 p-1 rounded-md text-muted hover:text-fg hover:bg-bg transition"
|
||||
aria-label={chat.minimized ? t("messages.expand") : t("messages.minimize")}
|
||||
>
|
||||
{chat.minimized ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => closeChat(chat.partnerId)}
|
||||
className="shrink-0 p-1 rounded-md text-muted hover:text-fg hover:bg-bg transition"
|
||||
aria-label={t("messages.close")}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
{!chat.minimized && (
|
||||
<div className="flex flex-col h-96">
|
||||
<ChatThread meId={meId} partnerId={chat.partnerId} isSystem={false} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
162
frontend/src/components/ChatThread.tsx
Normal file
162
frontend/src/components/ChatThread.tsx
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
import { useEffect, useRef, useState } from "react";
|
||||
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 { relativeTime } from "../lib/format";
|
||||
import { partnerPub, useKeyState } from "../lib/messaging";
|
||||
import * as e2ee from "../lib/e2ee";
|
||||
import KeyGate from "./KeyGate";
|
||||
|
||||
const POLL_MS = 20000; // safety net; live updates arrive over the WebSocket
|
||||
|
||||
// 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 ready = useKeyState() === "ready";
|
||||
const needsKey = !isSystem && !ready;
|
||||
|
||||
const q = useLiveQuery<{ partner: MessageUser; items: Message[] }>(
|
||||
["thread", partnerId],
|
||||
() => api.thread(partnerId),
|
||||
{ intervalMs: POLL_MS, enabled: !needsKey }
|
||||
);
|
||||
const items = q.data?.items ?? [];
|
||||
|
||||
useEffect(() => {
|
||||
if (isSystem || !ready) return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
const out: Record<number, string> = {};
|
||||
for (const m of items) {
|
||||
if (m.ciphertext && m.iv) {
|
||||
try {
|
||||
const pub = await partnerPub(partnerId);
|
||||
out[m.id] = await e2ee.decryptFrom(partnerId, pub, m.ciphertext, m.iv);
|
||||
} catch {
|
||||
out[m.id] = "⚠ " + t("messages.cantDecrypt");
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!cancelled) setPlain(out);
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [items, ready, isSystem, partnerId, t]);
|
||||
|
||||
// Opening / refreshing the thread marks incoming messages read, so clear the badges.
|
||||
useEffect(() => {
|
||||
if (q.dataUpdatedAt) {
|
||||
qc.invalidateQueries({ queryKey: ["conversations"] });
|
||||
qc.invalidateQueries({ queryKey: ["message-unread"] });
|
||||
}
|
||||
}, [q.dataUpdatedAt, qc]);
|
||||
|
||||
useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView({ block: "end" });
|
||||
}, [items.length, plain]);
|
||||
|
||||
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: ["thread", partnerId] });
|
||||
qc.invalidateQueries({ queryKey: ["conversations"] });
|
||||
},
|
||||
});
|
||||
|
||||
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 className="flex-1 overflow-y-auto p-3 flex flex-col gap-2">
|
||||
{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 break-words ${
|
||||
mine ? "bg-accent text-accent-fg rounded-br-sm" : "bg-card rounded-bl-sm"
|
||||
}`}
|
||||
>
|
||||
{text}
|
||||
<div className={`text-[10px] mt-1 ${mine ? "text-accent-fg/70" : "text-muted"}`}>
|
||||
{relativeTime(m.created_at)}
|
||||
</div>
|
||||
</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
|
||||
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")}
|
||||
className="flex-1 resize-none max-h-28 bg-card border border-border rounded-xl px-3 py-2 text-sm outline-none focus:border-accent"
|
||||
/>
|
||||
<button
|
||||
onClick={submit}
|
||||
disabled={!draft.trim() || send.isPending}
|
||||
className="shrink-0 p-2.5 rounded-xl bg-accent text-accent-fg hover:opacity-90 transition disabled:opacity-40"
|
||||
aria-label={t("messages.send")}
|
||||
title={t("messages.send")}
|
||||
>
|
||||
<Send className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -79,7 +79,11 @@ export default function Header({
|
|||
? t("header.configuration")
|
||||
: page === "users"
|
||||
? t("header.users")
|
||||
: t("header.channelManager")}
|
||||
: page === "notifications"
|
||||
? t("inbox.navLabel")
|
||||
: page === "messages"
|
||||
? t("messages.navLabel")
|
||||
: t("header.channelManager")}
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
|
|
|
|||
80
frontend/src/components/KeyGate.tsx
Normal file
80
frontend/src/components/KeyGate.tsx
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Lock, ShieldCheck } from "lucide-react";
|
||||
import { api } from "../lib/api";
|
||||
import * as e2ee from "../lib/e2ee";
|
||||
|
||||
// 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.
|
||||
export default function KeyGate({ meId, compact = false }: { meId: number; compact?: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const keyQ = useQuery({ queryKey: ["message-key"], queryFn: api.messageMyKey, staleTime: 60_000 });
|
||||
const configured = keyQ.data?.configured ?? false;
|
||||
|
||||
const [pass, setPass] = useState("");
|
||||
const [confirm, setConfirm] = useState("");
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
async function submit() {
|
||||
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: ["message-key"] });
|
||||
} catch {
|
||||
setErr(configured ? t("messages.wrongPass") : t("messages.setupFailed"));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (keyQ.isLoading) return <div className="p-6 text-center text-muted text-sm">{t("common.loading")}</div>;
|
||||
|
||||
return (
|
||||
<div className={`glass rounded-2xl 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
|
||||
type="password"
|
||||
value={pass}
|
||||
onChange={(e) => setPass(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && configured && submit()}
|
||||
placeholder={t("messages.passphrase")}
|
||||
className="w-full bg-card border border-border rounded-xl px-3 py-2 text-sm outline-none focus:border-accent"
|
||||
/>
|
||||
{!configured && (
|
||||
<input
|
||||
type="password"
|
||||
value={confirm}
|
||||
onChange={(e) => setConfirm(e.target.value)}
|
||||
placeholder={t("messages.passphraseConfirm")}
|
||||
className="w-full bg-card border border-border rounded-xl px-3 py-2 text-sm outline-none focus:border-accent"
|
||||
/>
|
||||
)}
|
||||
{err && <div className="text-sm text-red-400">{err}</div>}
|
||||
<button
|
||||
onClick={submit}
|
||||
disabled={busy || !pass}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 rounded-xl font-semibold bg-accent text-accent-fg hover:opacity-90 transition disabled:opacity-40"
|
||||
>
|
||||
<Lock className="w-4 h-4" />
|
||||
{configured ? t("messages.unlock") : t("messages.setUp")}
|
||||
</button>
|
||||
{!configured && !compact && <p className="text-xs text-muted">{t("messages.setupWarn")}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
236
frontend/src/components/Messages.tsx
Normal file
236
frontend/src/components/Messages.tsx
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
import { useEffect, useState } from "react";
|
||||
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 { relativeTime } from "../lib/format";
|
||||
import { partnerPub, SYSTEM_ID, useKeyState } from "../lib/messaging";
|
||||
import { openChat } from "../lib/chatDock";
|
||||
import * as e2ee from "../lib/e2ee";
|
||||
import Avatar from "./Avatar";
|
||||
import KeyGate from "./KeyGate";
|
||||
import ChatThread from "./ChatThread";
|
||||
|
||||
const POLL_MS = 20000;
|
||||
|
||||
type View = "list" | "new" | { partnerId: number; name?: string; avatar?: string | null; system?: boolean };
|
||||
|
||||
export default function Messages({ meId }: { meId: number }) {
|
||||
const [view, setView] = useState<View>("list");
|
||||
|
||||
if (typeof view === "object") {
|
||||
return (
|
||||
<PageThread
|
||||
meId={meId}
|
||||
partnerId={view.partnerId}
|
||||
isSystem={!!view.system}
|
||||
seedName={view.name}
|
||||
seedAvatar={view.avatar}
|
||||
onBack={() => setView("list")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (view === "new") {
|
||||
return <Directory onPick={(u) => setView({ partnerId: u.id, name: u.name, avatar: u.avatar_url })} onBack={() => setView("list")} />;
|
||||
}
|
||||
return (
|
||||
<ConversationList
|
||||
meId={meId}
|
||||
onOpen={(c) => setView({ partnerId: c.partner.id, name: c.partner.name, avatar: c.partner.avatar_url, system: c.partner.id === SYSTEM_ID })}
|
||||
onNew={() => setView("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[] }>(["conversations"], api.conversations, { intervalMs: POLL_MS });
|
||||
const items = q.data?.items ?? [];
|
||||
const [previews, setPreviews] = useState<Record<number, string>>({});
|
||||
|
||||
useEffect(() => {
|
||||
if (!ready) return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
const out: Record<number, string> = {};
|
||||
for (const c of 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;
|
||||
};
|
||||
}, [items, 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">
|
||||
<div className="text-xs uppercase tracking-wide text-muted">{t("messages.conversations")}</div>
|
||||
{ready && (
|
||||
<button
|
||||
onClick={onNew}
|
||||
className="inline-flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded-lg bg-accent text-accent-fg hover:opacity-90 transition"
|
||||
>
|
||||
<SquarePen className="w-4 h-4" />
|
||||
{t("messages.new")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{(keyState === "setup" || keyState === "unlock") && <KeyGate meId={meId} />}
|
||||
{q.isLoading && !q.data ? (
|
||||
<div className="p-8 text-muted text-center">{t("common.loading")}</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="glass rounded-2xl p-10 text-center text-muted">{t("messages.empty")}</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{items.map((c) => {
|
||||
const isSystem = c.partner.id === SYSTEM_ID;
|
||||
return (
|
||||
<div key={c.partner.id} className="group flex items-center gap-1 rounded-xl hover:bg-card transition">
|
||||
<button onClick={() => onOpen(c)} className="flex items-center gap-3 p-2.5 text-left 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>
|
||||
<span className="ml-auto text-[11px] text-muted shrink-0">{relativeTime(c.last_message.created_at)}</span>
|
||||
</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-[11px] font-semibold">
|
||||
{c.unread}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
{!isSystem && ready && (
|
||||
<button
|
||||
onClick={() => openChat(c.partner)}
|
||||
title={t("messages.popOut")}
|
||||
aria-label={t("messages.popOut")}
|
||||
className="shrink-0 mr-1.5 p-1.5 rounded-lg text-muted opacity-0 group-hover:opacity-100 hover:text-accent hover:bg-bg transition"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Directory({ onPick, onBack }: { onPick: (u: MessageUser) => void; onBack: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const [filter, setFilter] = useState("");
|
||||
const q = useQuery({ queryKey: ["message-users"], 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">
|
||||
<button onClick={onBack} className="p-1.5 rounded-lg text-muted hover:text-fg hover:bg-card transition">
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
</button>
|
||||
<div className="text-xs uppercase tracking-wide text-muted">{t("messages.newTitle")}</div>
|
||||
</div>
|
||||
<input
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
placeholder={t("messages.searchPeople")}
|
||||
className="w-full bg-card border border-border rounded-xl px-3 py-2 text-sm outline-none focus:border-accent"
|
||||
/>
|
||||
{q.isLoading ? (
|
||||
<div className="p-8 text-muted text-center">{t("common.loading")}</div>
|
||||
) : users.length === 0 ? (
|
||||
<div className="glass rounded-2xl p-10 text-center text-muted">{t("messages.noPeople")}</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1">
|
||||
{users.map((u) => (
|
||||
<button
|
||||
key={u.id}
|
||||
onClick={() => onPick(u)}
|
||||
className="flex items-center gap-3 p-2.5 rounded-xl text-left hover:bg-card transition"
|
||||
>
|
||||
<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>
|
||||
</button>
|
||||
))}
|
||||
</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-[65vh] min-h-80">
|
||||
<div className="flex items-center gap-3 pb-3 border-b border-border">
|
||||
<button onClick={onBack} className="p-1.5 rounded-lg text-muted hover:text-fg hover:bg-card transition">
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
</button>
|
||||
<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 && (
|
||||
<button
|
||||
onClick={() => openChat({ id: partnerId, name, avatar_url: seedAvatar ?? null })}
|
||||
title={t("messages.popOut")}
|
||||
aria-label={t("messages.popOut")}
|
||||
className="p-1.5 rounded-lg text-muted hover:text-accent hover:bg-card transition"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<ChatThread meId={meId} partnerId={partnerId} isSystem={isSystem} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -12,6 +12,7 @@ import {
|
|||
Info,
|
||||
ListVideo,
|
||||
LogOut,
|
||||
MessageSquare,
|
||||
Settings,
|
||||
Shield,
|
||||
SlidersHorizontal,
|
||||
|
|
@ -127,6 +128,13 @@ 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(
|
||||
["message-unread"],
|
||||
api.messageUnreadCount,
|
||||
{ intervalMs: 30000, enabled: !me.is_demo }
|
||||
);
|
||||
const msgUnread = msgUnreadQuery.data?.count ?? 0;
|
||||
|
||||
type NavItem = { page: Page; icon: typeof Home; label: string; badge?: number };
|
||||
// User-facing content modules vs. admin/system modules, separated by a divider in the rail.
|
||||
|
|
@ -135,6 +143,10 @@ export default function NavSidebar({
|
|||
{ page: "channels", icon: Tv, label: t("header.account.channels") },
|
||||
{ page: "playlists", icon: ListVideo, label: t("header.account.playlists") },
|
||||
{ page: "notifications", icon: Bell, label: t("inbox.navLabel"), badge: unread },
|
||||
// Direct messaging — its own module; hidden for the shared demo account.
|
||||
...(me.is_demo
|
||||
? []
|
||||
: [{ page: "messages" as Page, icon: MessageSquare, label: t("messages.navLabel"), badge: msgUnread }]),
|
||||
// Per-user sync status + your own API usage (admins get an extra system-wide tab inside).
|
||||
{ page: "stats", icon: BarChart3, label: t("header.account.stats") },
|
||||
];
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
"navLabel": "Benachrichtigungen",
|
||||
"title": "Benachrichtigungen",
|
||||
"subtitle": "Neuigkeiten aus deiner Bibliothek und vom System.",
|
||||
"tabInbox": "Benachrichtigungen",
|
||||
"empty": "Alles erledigt.",
|
||||
"markAllRead": "Alle als gelesen markieren",
|
||||
"clearAll": "Alle löschen",
|
||||
|
|
|
|||
34
frontend/src/i18n/locales/de/messages.json
Normal file
34
frontend/src/i18n/locales/de/messages.json
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
{
|
||||
"tab": "Nachrichten",
|
||||
"navLabel": "Nachrichten",
|
||||
"popOut": "In Pop-out öffnen",
|
||||
"expand": "Aufklappen",
|
||||
"minimize": "Minimieren",
|
||||
"close": "Schließen",
|
||||
"conversations": "Unterhaltungen",
|
||||
"new": "Neue Nachricht",
|
||||
"newTitle": "Neue Nachricht",
|
||||
"empty": "Noch keine Unterhaltungen. Starte eine über die Schaltfläche „Neue Nachricht“.",
|
||||
"searchPeople": "Personen suchen…",
|
||||
"noPeople": "Noch niemand zum Schreiben.",
|
||||
"threadEmpty": "Noch keine Nachrichten — sag Hallo.",
|
||||
"writePlaceholder": "Nachricht schreiben…",
|
||||
"send": "Senden",
|
||||
"encrypted": "Verschlüsselte Nachricht",
|
||||
"encryptedHint": "Ende-zu-Ende-verschlüsselt",
|
||||
"cantDecrypt": "Kann nicht entschlüsselt werden",
|
||||
"systemReadOnly": "Dies ist eine automatische Nachricht.",
|
||||
"setupTitle": "Sichere Nachrichten einrichten",
|
||||
"setupBody": "Nachrichten sind Ende-zu-Ende-verschlüsselt. Wähle ein Passwort zum Schutz deines Schlüssels — er verlässt nie dein Gerät, sodass niemand (nicht einmal ein Admin) deine Unterhaltungen lesen kann.",
|
||||
"setupWarn": "Wenn du dieses Passwort vergisst, kann dein Nachrichtenverlauf nicht wiederhergestellt werden.",
|
||||
"setUp": "Einrichten",
|
||||
"unlockTitle": "Nachrichten entsperren",
|
||||
"unlockBody": "Gib dein Nachrichten-Passwort ein, um deine Unterhaltungen auf diesem Gerät zu entsperren.",
|
||||
"unlock": "Entsperren",
|
||||
"passphrase": "Passwort",
|
||||
"passphraseConfirm": "Passwort bestätigen",
|
||||
"passTooShort": "Mindestens 6 Zeichen verwenden.",
|
||||
"passMismatch": "Die Passwörter stimmen nicht überein.",
|
||||
"wrongPass": "Falsches Passwort.",
|
||||
"setupFailed": "Sichere Nachrichten konnten nicht eingerichtet werden. Bitte erneut versuchen."
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@
|
|||
"navLabel": "Notifications",
|
||||
"title": "Notifications",
|
||||
"subtitle": "Updates from your library and the system.",
|
||||
"tabInbox": "Notifications",
|
||||
"empty": "You're all caught up.",
|
||||
"markAllRead": "Mark all read",
|
||||
"clearAll": "Clear all",
|
||||
|
|
|
|||
34
frontend/src/i18n/locales/en/messages.json
Normal file
34
frontend/src/i18n/locales/en/messages.json
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
{
|
||||
"tab": "Messages",
|
||||
"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",
|
||||
"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."
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@
|
|||
"navLabel": "Értesítések",
|
||||
"title": "Értesítések",
|
||||
"subtitle": "Frissítések a könyvtáradból és a rendszertől.",
|
||||
"tabInbox": "Értesítések",
|
||||
"empty": "Nincs új értesítés.",
|
||||
"markAllRead": "Összes olvasott",
|
||||
"clearAll": "Összes törlése",
|
||||
|
|
|
|||
34
frontend/src/i18n/locales/hu/messages.json
Normal file
34
frontend/src/i18n/locales/hu/messages.json
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
{
|
||||
"tab": "Üzenetek",
|
||||
"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",
|
||||
"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": "A biztonságos üzenetküldés beállítása nem sikerült. Próbáld újra."
|
||||
}
|
||||
|
|
@ -151,6 +151,16 @@ html[data-perf="1"] body {
|
|||
from { opacity: 0; transform: translateY(-6px) scale(0.97); }
|
||||
to { opacity: 1; 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 ===== */
|
||||
|
||||
|
|
|
|||
|
|
@ -469,6 +469,48 @@ export interface AppNotification {
|
|||
created_at: string | null;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// Admin Configuration page: one DB-overridable setting (registry entry on the backend).
|
||||
export interface ConfigItem {
|
||||
key: string;
|
||||
|
|
@ -712,6 +754,23 @@ export const api = {
|
|||
req(`/api/me/notifications/${id}/dismiss`, { method: "POST" }),
|
||||
clearNotifications: () => req("/api/me/notifications/clear", { method: "POST" }),
|
||||
|
||||
// --- direct messages (end-to-end encrypted) ---
|
||||
messageMyKey: (): Promise<MyKeyBundle> => req("/api/messages/keys/me"),
|
||||
setupMessageKey: (k: KeySetup): Promise<{ configured: boolean }> =>
|
||||
req("/api/messages/keys", { method: "POST", body: JSON.stringify(k) }),
|
||||
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"),
|
||||
|
||||
// --- user tags ---
|
||||
createTag: (t: { name: string; color?: string; category?: string }) =>
|
||||
req("/api/tags", { method: "POST", body: JSON.stringify(t) }),
|
||||
|
|
|
|||
109
frontend/src/lib/chatDock.ts
Normal file
109
frontend/src/lib/chatDock.ts
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
// 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 { useSyncExternalStore } from "react";
|
||||
|
||||
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
|
||||
let chats: DockChat[] = [];
|
||||
let userId: number | null = null;
|
||||
const subs = new Set<() => void>();
|
||||
|
||||
function storageKey(): string | null {
|
||||
return userId != null ? `siftlode.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(chats.map(({ flash, ...c }) => c)));
|
||||
} catch {
|
||||
/* ignore quota/serialization errors */
|
||||
}
|
||||
}
|
||||
|
||||
function emit(next: DockChat[]): void {
|
||||
chats = next;
|
||||
persist();
|
||||
for (const s of subs) s();
|
||||
}
|
||||
|
||||
// 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 = [];
|
||||
}
|
||||
chats = restored.slice(0, MAX_OPEN);
|
||||
for (const s of subs) s();
|
||||
}
|
||||
|
||||
export function openChat(partner: PartnerLike): void {
|
||||
const existing = chats.find((c) => c.partnerId === partner.id);
|
||||
if (existing) {
|
||||
// Re-opening focuses it: ensure expanded and move to the front.
|
||||
emit([
|
||||
{ ...existing, minimized: false },
|
||||
...chats.filter((c) => c.partnerId !== partner.id),
|
||||
]);
|
||||
return;
|
||||
}
|
||||
emit(
|
||||
[
|
||||
{ partnerId: partner.id, name: partner.name, avatar: partner.avatar_url, minimized: false, flash: 0 },
|
||||
...chats,
|
||||
].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 = chats.find((c) => c.partnerId === partner.id);
|
||||
if (!existing) {
|
||||
emit(
|
||||
[
|
||||
{ partnerId: partner.id, name: partner.name, avatar: partner.avatar_url, minimized: false, flash: 1 },
|
||||
...chats,
|
||||
].slice(0, MAX_OPEN)
|
||||
);
|
||||
return;
|
||||
}
|
||||
emit(chats.map((c) => (c.partnerId === partner.id ? { ...c, flash: c.flash + 1 } : c)));
|
||||
}
|
||||
|
||||
export function closeChat(partnerId: number): void {
|
||||
emit(chats.filter((c) => c.partnerId !== partnerId));
|
||||
}
|
||||
|
||||
export function toggleMinimize(partnerId: number): void {
|
||||
emit(chats.map((c) => (c.partnerId === partnerId ? { ...c, minimized: !c.minimized } : c)));
|
||||
}
|
||||
|
||||
export function useDockChats(): DockChat[] {
|
||||
return useSyncExternalStore(
|
||||
(cb) => {
|
||||
subs.add(cb);
|
||||
return () => subs.delete(cb);
|
||||
},
|
||||
() => chats,
|
||||
() => chats
|
||||
);
|
||||
}
|
||||
204
frontend/src/lib/e2ee.ts
Normal file
204
frontend/src/lib/e2ee.ts
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
// 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;
|
||||
}
|
||||
|
||||
export function lock(): void {
|
||||
privKey = null;
|
||||
convKeys.clear();
|
||||
emitUnlock();
|
||||
}
|
||||
|
||||
// --- 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();
|
||||
});
|
||||
}
|
||||
|
||||
// --- 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;
|
||||
}
|
||||
|
||||
// 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));
|
||||
const keyCheck = await subtle.encrypt({ name: "AES-GCM", iv: bsrc(wrapIv) }, wrapKey, bsrc(enc.encode("ok")));
|
||||
const spki = await subtle.exportKey("spki", kp.publicKey);
|
||||
|
||||
privKey = await importPrivate(pkcs8);
|
||||
convKeys.clear();
|
||||
await idbPut(userId, privKey);
|
||||
emitUnlock();
|
||||
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 → AES-GCM auth tag fails here.
|
||||
await subtle.decrypt({ name: "AES-GCM", iv: bsrc(ub64(bundle.wrap_iv)) }, wrapKey, bsrc(ub64(bundle.key_check)));
|
||||
const pkcs8 = await subtle.decrypt(
|
||||
{ name: "AES-GCM", iv: bsrc(ub64(bundle.wrap_iv)) },
|
||||
wrapKey,
|
||||
bsrc(ub64(bundle.wrapped_private_key))
|
||||
);
|
||||
privKey = await importPrivate(pkcs8);
|
||||
convKeys.clear();
|
||||
await idbPut(userId, privKey);
|
||||
emitUnlock();
|
||||
}
|
||||
|
||||
// --- 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);
|
||||
}
|
||||
85
frontend/src/lib/messagesSocket.ts
Normal file
85
frontend/src/lib/messagesSocket.ts
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
// 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 type { Message, MessageUser } from "./api";
|
||||
|
||||
// A pushed message plus both parties, so the dock can open/flash the right window.
|
||||
export interface IncomingMessage {
|
||||
message: Message;
|
||||
from?: MessageUser;
|
||||
to?: MessageUser;
|
||||
}
|
||||
type Handler = (e: IncomingMessage) => void;
|
||||
|
||||
const handlers = new Set<Handler>();
|
||||
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";
|
||||
return `${proto}://${location.host}/api/messages/ws`;
|
||||
}
|
||||
|
||||
function open() {
|
||||
try {
|
||||
ws = new WebSocket(url());
|
||||
} catch {
|
||||
schedule();
|
||||
return;
|
||||
}
|
||||
ws.onopen = () => {
|
||||
backoff = 1000;
|
||||
};
|
||||
ws.onmessage = (ev) => {
|
||||
try {
|
||||
const data = JSON.parse(ev.data);
|
||||
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);
|
||||
}
|
||||
} catch {
|
||||
/* ignore malformed frames */
|
||||
}
|
||||
};
|
||||
ws.onclose = () => {
|
||||
ws = null;
|
||||
if (started) schedule();
|
||||
};
|
||||
ws.onerror = () => {
|
||||
ws?.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;
|
||||
}
|
||||
};
|
||||
}
|
||||
36
frontend/src/lib/messaging.ts
Normal file
36
frontend/src/lib/messaging.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
// Shared messaging helpers used by both the Messages page and the floating chat dock.
|
||||
import { useSyncExternalStore } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { api } from "./api";
|
||||
import { isUnlocked, subscribeUnlock } from "./e2ee";
|
||||
|
||||
export const SYSTEM_ID = 0; // synthetic "Siftlode" system conversation partner id
|
||||
|
||||
// Set-once cache of partner public keys (the server is the directory; keys never change).
|
||||
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;
|
||||
}
|
||||
|
||||
// Live "is secure messaging unlocked on this device" — shared so the page and the dock agree.
|
||||
export 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".
|
||||
export type KeyState = "loading" | "setup" | "unlock" | "ready";
|
||||
export function useKeyState(): KeyState {
|
||||
const unlocked = useUnlocked();
|
||||
const q = useQuery({ queryKey: ["message-key"], 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";
|
||||
}
|
||||
|
|
@ -91,6 +91,7 @@ export const PAGES = [
|
|||
"config",
|
||||
"users",
|
||||
"notifications",
|
||||
"messages",
|
||||
] as const;
|
||||
|
||||
export type Page = (typeof PAGES)[number];
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue