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,
|
feed,
|
||||||
health,
|
health,
|
||||||
me,
|
me,
|
||||||
|
messages,
|
||||||
notifications,
|
notifications,
|
||||||
playlists,
|
playlists,
|
||||||
quota,
|
quota,
|
||||||
|
|
@ -114,6 +115,7 @@ app.include_router(tags.router)
|
||||||
app.include_router(feed.router)
|
app.include_router(feed.router)
|
||||||
app.include_router(me.router)
|
app.include_router(me.router)
|
||||||
app.include_router(notifications.router)
|
app.include_router(notifications.router)
|
||||||
|
app.include_router(messages.router)
|
||||||
app.include_router(channels.router)
|
app.include_router(channels.router)
|
||||||
app.include_router(playlists.router)
|
app.include_router(playlists.router)
|
||||||
app.include_router(admin.router)
|
app.include_router(admin.router)
|
||||||
|
|
|
||||||
|
|
@ -518,3 +518,69 @@ class Notification(Base):
|
||||||
created_at: Mapped[datetime] = mapped_column(
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime(timezone=True), server_default=func.now(), index=True
|
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 AdminUsers from "./components/AdminUsers";
|
||||||
import SettingsPanel from "./components/SettingsPanel";
|
import SettingsPanel from "./components/SettingsPanel";
|
||||||
import NotificationsPanel from "./components/NotificationsPanel";
|
import NotificationsPanel from "./components/NotificationsPanel";
|
||||||
|
import Messages from "./components/Messages";
|
||||||
|
import ChatDock from "./components/ChatDock";
|
||||||
import OnboardingWizard from "./components/OnboardingWizard";
|
import OnboardingWizard from "./components/OnboardingWizard";
|
||||||
import { shouldAutoOpenOnboarding } from "./lib/onboarding";
|
import { shouldAutoOpenOnboarding } from "./lib/onboarding";
|
||||||
import Toaster from "./components/Toaster";
|
import Toaster from "./components/Toaster";
|
||||||
|
|
@ -546,6 +548,10 @@ export default function App() {
|
||||||
<Playlists canWrite={meQuery.data!.can_write} />
|
<Playlists canWrite={meQuery.data!.can_write} />
|
||||||
) : page === "notifications" ? (
|
) : page === "notifications" ? (
|
||||||
<NotificationsPanel filters={filters} setFilters={setFilters} setPage={setPage} onFocusChannel={focusChannel} />
|
<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" ? (
|
) : page === "settings" ? (
|
||||||
<SettingsPanel
|
<SettingsPanel
|
||||||
me={meQuery.data!}
|
me={meQuery.data!}
|
||||||
|
|
@ -569,6 +575,7 @@ export default function App() {
|
||||||
(collapsed or expanded) without tracking its width. */}
|
(collapsed or expanded) without tracking its width. */}
|
||||||
<Toaster />
|
<Toaster />
|
||||||
</div>
|
</div>
|
||||||
|
{meQuery.data && !meQuery.data.is_demo && <ChatDock meId={meQuery.data.id} />}
|
||||||
{wizardOpen && meQuery.data && !meQuery.data.is_demo && (
|
{wizardOpen && meQuery.data && !meQuery.data.is_demo && (
|
||||||
<OnboardingWizard me={meQuery.data} onClose={() => setWizardOpen(false)} />
|
<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,6 +79,10 @@ export default function Header({
|
||||||
? t("header.configuration")
|
? t("header.configuration")
|
||||||
: page === "users"
|
: page === "users"
|
||||||
? t("header.users")
|
? t("header.users")
|
||||||
|
: page === "notifications"
|
||||||
|
? t("inbox.navLabel")
|
||||||
|
: page === "messages"
|
||||||
|
? t("messages.navLabel")
|
||||||
: t("header.channelManager")}
|
: t("header.channelManager")}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
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,
|
Info,
|
||||||
ListVideo,
|
ListVideo,
|
||||||
LogOut,
|
LogOut,
|
||||||
|
MessageSquare,
|
||||||
Settings,
|
Settings,
|
||||||
Shield,
|
Shield,
|
||||||
SlidersHorizontal,
|
SlidersHorizontal,
|
||||||
|
|
@ -127,6 +128,13 @@ export default function NavSidebar({
|
||||||
// events (the former separate bell is now folded into the inbox page).
|
// events (the former separate bell is now folded into the inbox page).
|
||||||
const clientUnread = useSyncExternalStore(subscribe, getUnreadCount, getUnreadCount);
|
const clientUnread = useSyncExternalStore(subscribe, getUnreadCount, getUnreadCount);
|
||||||
const unread = (unreadQuery.data?.count ?? 0) + clientUnread;
|
const unread = (unreadQuery.data?.count ?? 0) + clientUnread;
|
||||||
|
// Direct messages are their own module with their own unread badge (demo can't message).
|
||||||
|
const msgUnreadQuery = useLiveQuery(
|
||||||
|
["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 };
|
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.
|
// 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: "channels", icon: Tv, label: t("header.account.channels") },
|
||||||
{ page: "playlists", icon: ListVideo, label: t("header.account.playlists") },
|
{ page: "playlists", icon: ListVideo, label: t("header.account.playlists") },
|
||||||
{ page: "notifications", icon: Bell, label: t("inbox.navLabel"), badge: unread },
|
{ 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).
|
// 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") },
|
{ page: "stats", icon: BarChart3, label: t("header.account.stats") },
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
"navLabel": "Benachrichtigungen",
|
"navLabel": "Benachrichtigungen",
|
||||||
"title": "Benachrichtigungen",
|
"title": "Benachrichtigungen",
|
||||||
"subtitle": "Neuigkeiten aus deiner Bibliothek und vom System.",
|
"subtitle": "Neuigkeiten aus deiner Bibliothek und vom System.",
|
||||||
|
"tabInbox": "Benachrichtigungen",
|
||||||
"empty": "Alles erledigt.",
|
"empty": "Alles erledigt.",
|
||||||
"markAllRead": "Alle als gelesen markieren",
|
"markAllRead": "Alle als gelesen markieren",
|
||||||
"clearAll": "Alle löschen",
|
"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",
|
"navLabel": "Notifications",
|
||||||
"title": "Notifications",
|
"title": "Notifications",
|
||||||
"subtitle": "Updates from your library and the system.",
|
"subtitle": "Updates from your library and the system.",
|
||||||
|
"tabInbox": "Notifications",
|
||||||
"empty": "You're all caught up.",
|
"empty": "You're all caught up.",
|
||||||
"markAllRead": "Mark all read",
|
"markAllRead": "Mark all read",
|
||||||
"clearAll": "Clear all",
|
"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",
|
"navLabel": "Értesítések",
|
||||||
"title": "Értesítések",
|
"title": "Értesítések",
|
||||||
"subtitle": "Frissítések a könyvtáradból és a rendszertől.",
|
"subtitle": "Frissítések a könyvtáradból és a rendszertől.",
|
||||||
|
"tabInbox": "Értesítések",
|
||||||
"empty": "Nincs új értesítés.",
|
"empty": "Nincs új értesítés.",
|
||||||
"markAllRead": "Összes olvasott",
|
"markAllRead": "Összes olvasott",
|
||||||
"clearAll": "Összes törlése",
|
"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); }
|
from { opacity: 0; transform: translateY(-6px) scale(0.97); }
|
||||||
to { opacity: 1; transform: none; }
|
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 ===== */
|
/* ===== Color schemes (accent + neutrals), each with dark + light ===== */
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -469,6 +469,48 @@ export interface AppNotification {
|
||||||
created_at: string | null;
|
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).
|
// Admin Configuration page: one DB-overridable setting (registry entry on the backend).
|
||||||
export interface ConfigItem {
|
export interface ConfigItem {
|
||||||
key: string;
|
key: string;
|
||||||
|
|
@ -712,6 +754,23 @@ export const api = {
|
||||||
req(`/api/me/notifications/${id}/dismiss`, { method: "POST" }),
|
req(`/api/me/notifications/${id}/dismiss`, { method: "POST" }),
|
||||||
clearNotifications: () => req("/api/me/notifications/clear", { 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 ---
|
// --- user tags ---
|
||||||
createTag: (t: { name: string; color?: string; category?: string }) =>
|
createTag: (t: { name: string; color?: string; category?: string }) =>
|
||||||
req("/api/tags", { method: "POST", body: JSON.stringify(t) }),
|
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",
|
"config",
|
||||||
"users",
|
"users",
|
||||||
"notifications",
|
"notifications",
|
||||||
|
"messages",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type Page = (typeof PAGES)[number];
|
export type Page = (typeof PAGES)[number];
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue