Merge branch 'refactor/structure'

This commit is contained in:
2025-11-26 16:42:17 +03:00
Unverified
56 changed files with 3456 additions and 1108 deletions
+4 -2
View File
@@ -1,9 +1,11 @@
import os
DATABASE_URL = "sqlite:///./data/database.db"
JWT_ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_HOURS = 24
# Token inactivity expiration - token expires if not used for this duration
TOKEN_INACTIVITY_EXPIRE_HOURS = 30 * 24 # 30 days of inactivity
# Maximum token lifetime (safety net) - tokens expire after this regardless of usage
MAX_TOKEN_LIFETIME_HOURS = 365 * 24 # 1 year maximum
OWNER_USERNAME = "denis0001-dev"
JWT_SECRET_KEY = os.getenv("JWT_SECRET")
+15 -2
View File
@@ -1,4 +1,4 @@
from datetime import datetime
from datetime import datetime, timedelta
from fastapi import Depends, HTTPException, Request, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy.orm import Session
@@ -66,7 +66,20 @@ def get_current_user(
headers={"WWW-Authenticate": "Bearer"},
)
# Touch last_seen on valid session
# Check if session has been inactive for too long (sliding expiration)
from constants import TOKEN_INACTIVITY_EXPIRE_HOURS
inactivity_threshold = datetime.now() - timedelta(hours=TOKEN_INACTIVITY_EXPIRE_HOURS)
if device_session.last_seen < inactivity_threshold:
# Session expired due to inactivity - revoke it
device_session.revoked = True
db.commit()
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Session expired due to inactivity",
headers={"WWW-Authenticate": "Bearer"},
)
# Touch last_seen on valid session (sliding expiration - extends token life)
device_session.last_seen = datetime.now()
db.commit()
+15
View File
@@ -283,5 +283,20 @@ class DMReactionResponse(BaseModel):
from_attributes = True
class UpdateLog(Base):
"""Stores update sequence numbers and updates for gap detection"""
__tablename__ = "update_log"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("user.id"), nullable=False, index=True)
sequence = Column(Integer, nullable=False, index=True)
updates = Column(Text, nullable=False) # JSON array of updates
timestamp = Column(DateTime, default=datetime.now, index=True)
__table_args__ = (
UniqueConstraint("user_id", "sequence", name="uq_user_sequence"),
)
# Tables are now created through Alembic migrations
# Base.metadata.create_all(bind=engine)
File diff suppressed because it is too large Load Diff
+20 -2
View File
@@ -204,7 +204,16 @@ def _render_public_chat(action: str, fields: Dict[str, Any]) -> List[str]:
attachments = fields.get("attachments")
if attachments:
lines.append(f"Attachments: {_plural('file', attachments)}")
if fields.get("content"):
# If content was censored, log both raw and censored versions
if fields.get("raw_content") is not None:
lines.append("Raw content (before censoring):")
for line in unescape(fields["raw_content"]).splitlines():
lines.append(f"| {line}")
lines.append("Censored content (stored):")
for line in unescape(fields.get("censored_content", fields.get("content", ""))).splitlines():
lines.append(f"| {line}")
elif fields.get("content"):
lines.append("Content:")
for line in unescape(fields["content"]).splitlines():
lines.append(f"| {line}")
@@ -217,7 +226,16 @@ def _render_public_chat(action: str, fields: Dict[str, Any]) -> List[str]:
lines.append("Previous content:")
for line in unescape(fields["previous_content"] or "").splitlines() or [""]:
lines.append(f"| {line}")
if fields.get("content"):
# If content was censored, log both raw and censored versions
if fields.get("raw_content") is not None:
lines.append("Raw content (before censoring):")
for line in unescape(fields["raw_content"]).splitlines():
lines.append(f"| {line}")
lines.append("Censored content (stored):")
for line in unescape(fields.get("censored_content", fields.get("content", ""))).splitlines():
lines.append(f"| {line}")
elif fields.get("content"):
lines.append("New content:")
for line in unescape(fields["content"] or "").splitlines() or [""]:
lines.append(f"| {line}")
+512 -54
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
import json
import re
import unicodedata
from pathlib import Path
from threading import RLock
from typing import Iterable, List, Set, Tuple
@@ -13,8 +14,8 @@ BLOCKLIST_PATH.parent.mkdir(parents=True, exist_ok=True)
_CUSTOM_RU_TERMS: Set[str] = {
"бляд", "блять", "бля", "сука", "суки", "сучка", "мразь", "ебан",
"ебать", "ебёт", "ебет", "уёбок", "уебок", "уебище", "пизда",
"пиздец", "пизд", "хуй", "хуя", "хуе", "хуё", "хер", "гондон",
"ебать", "ебёт", "ебет", "ебаная", "ебаная", "уёбок", "уебок", "уебище", "пизда",
"пиздец", "пизд", "хуй", "хуя", "хуе", "хуё", "хуйня", "хер", "гондон",
"долбоёб", "долбоеб", "дебил", "член", "проститутка", "проститутки",
"урод", "хуесос", "хуесосы", "хуесосов", "хуесоса", "пидор",
"пидоры", "пидорас", "пидорасы", "пидорасов",
@@ -28,6 +29,12 @@ _ADULT_TERMS: Set[str] = {
_STATIC_TERMS: Set[str] = set(term.lower() for term in (_CUSTOM_RU_TERMS | _ADULT_TERMS))
# Words that should never be censored (whitelist)
_WHITELIST: Set[str] = {
"говно", # Allow this word
}
# Phrase patterns - these will be applied to normalized text (without special chars)
_PHRASE_PATTERNS: Tuple[re.Pattern[str], ...] = (
re.compile(r"\bmax\s+is\s+better\b", re.IGNORECASE | re.UNICODE),
re.compile(r"\bмакс\s+лучше\b", re.IGNORECASE | re.UNICODE),
@@ -39,42 +46,142 @@ _PHRASE_PATTERNS: Tuple[re.Pattern[str], ...] = (
re.compile(r"\bсамсунг\s+г[ао]вно\b", re.IGNORECASE | re.UNICODE),
)
# Map for normalizing homoglyphs (similar-looking characters)
# Maps English/Latin characters to their Cyrillic equivalents and vice versa
# Also includes Greek, full-width, and other Unicode variants
_LEET_MAP = {
# Numbers to letters
"0": "о",
"o": "о",
"о": "о",
"a": "а",
"@": "а",
"4": "а",
"а": "а",
"e": "е",
"ё": "е",
"3": "е",
"c": "с",
"s": "с",
"с": "с",
"x": "х",
"х": "х",
"t": "т",
"т": "т",
"p": "п",
"п": "п",
"n": "н",
"н": "н",
"m": "м",
"м": "м",
"y": "у",
"u": "у",
"у": "у",
"g": "г",
"г": "г",
"v": "в",
"в": "в",
"f": "ф",
"ф": "ф",
"i": "и",
"1": "и",
"3": "е",
"4": "а",
# Latin to Cyrillic (lowercase)
"a": "а",
"c": "с",
"e": "е",
"f": "ф",
"g": "г",
"i": "и",
"m": "м",
"n": "н",
"o": "о",
"p": "п",
"s": "с",
"t": "т",
"u": "у",
"v": "в",
"x": "х",
"y": "у",
"z": "з", # English 'z' to Cyrillic 'з'
# Latin to Cyrillic (uppercase)
"A": "а",
"C": "с",
"E": "е",
"F": "ф",
"G": "г",
"I": "и",
"M": "м",
"N": "н",
"O": "о",
"P": "п",
"S": "с",
"T": "т",
"U": "у",
"V": "в",
"X": "х",
"Y": "у",
"Z": "з", # English 'Z' to Cyrillic 'з'
# Greek letters that look like Cyrillic/Latin
"α": "а", # Greek alpha
"Α": "а",
"ο": "о", # Greek omicron
"Ο": "о",
"ρ": "р", # Greek rho (looks like Cyrillic р)
"Ρ": "р",
"υ": "у", # Greek upsilon
"Υ": "у",
"χ": "х", # Greek chi
"Χ": "х",
"ε": "е", # Greek epsilon
"Ε": "е",
"ι": "и", # Greek iota
"Ι": "и",
"ν": "н", # Greek nu
"Ν": "н",
"μ": "м", # Greek mu
"Μ": "м",
"π": "п", # Greek pi
"Π": "п",
"τ": "т", # Greek tau
"Τ": "т",
"γ": "г", # Greek gamma
"Γ": "г",
"σ": "с", # Greek sigma
"Σ": "с",
"φ": "ф", # Greek phi
"Φ": "ф",
# Full-width Latin characters
"": "а",
"": "а",
"": "с",
"": "с",
"": "е",
"": "е",
"": "ф",
"": "ф",
"": "г",
"": "г",
"": "и",
"": "и",
"": "м",
"": "м",
"": "н",
"": "н",
"": "о",
"": "о",
"": "п",
"": "п",
"": "с",
"": "с",
"": "т",
"": "т",
"": "у",
"": "у",
"": "в",
"": "в",
"": "х",
"": "х",
"": "у",
"": "у",
"": "з", # Full-width 'z' to Cyrillic 'з'
"": "з",
# Cyrillic to canonical Cyrillic (identity mappings)
"а": "а",
"с": "с",
"е": "е",
"ё": "е",
"ф": "ф",
"г": "г",
"и": "и",
"м": "м",
"н": "н",
"о": "о",
"п": "п",
"т": "т",
"у": "у",
"ү": "у", # Cyrillic capital U (U+04AE)
"Ү": "у", # Cyrillic capital U (U+04AE)
"в": "в",
"х": "х",
"р": "р",
"з": "з", # Cyrillic 'з'
"д": "д", # Cyrillic 'д'
"б": "б", # Cyrillic 'б'
"л": "л", # Cyrillic 'л'
"я": "я", # Cyrillic 'я'
"н": "н", # Already mapped, but explicit
# Special characters
"@": "а",
}
_RAW_PHRASE_GROUPS: Tuple[Tuple[str, Tuple[str, ...]], ...] = (
@@ -87,14 +194,240 @@ _PHRASE_CACHE: dict[str, Tuple[Tuple[str, ...], ...]] = {}
def _normalize_char(ch: str) -> str:
"""Normalize a single character, mapping homoglyphs to canonical form."""
# First try direct mapping (preserves case for non-mapped chars)
if ch in _LEET_MAP:
return _LEET_MAP[ch]
# Then try lowercase mapping
lower = ch.lower()
return _LEET_MAP.get(lower, lower)
if lower in _LEET_MAP:
return _LEET_MAP[lower]
# If no mapping and character is ASCII letter, return lowercase
# This preserves English words like "fromchat" as-is
if ch.isascii() and ch.isalpha():
return lower
# For other characters, return lowercase for consistency
return lower
def _normalize_token(token: str) -> str:
"""Normalize a token by mapping all homoglyphs."""
return "".join(_normalize_char(ch) for ch in token)
def _normalize_text_for_profanity(text: str) -> str:
"""
Normalize entire text by mapping homoglyphs to canonical forms.
This prevents bypasses like using English 'u' instead of Russian 'у'.
"""
return "".join(_normalize_char(ch) for ch in text)
def _strip_zero_width_chars(text: str) -> str:
"""
Remove zero-width characters that could be used to bypass filters.
"""
# Zero-width space, zero-width non-joiner, zero-width joiner, etc.
zero_width_chars = [
'\u200B', # Zero-width space
'\u200C', # Zero-width non-joiner
'\u200D', # Zero-width joiner
'\uFEFF', # Zero-width no-break space
'\u2060', # Word joiner
'\u2061', # Function application
'\u2062', # Invisible times
'\u2063', # Invisible separator
'\u2064', # Invisible plus
]
result = text
for zw_char in zero_width_chars:
result = result.replace(zw_char, '')
return result
def _extract_alphanumeric_with_mapping(text: str, preserve_spaces: bool = False) -> tuple[str, list[int]]:
"""
Extract only alphanumeric characters from text and create a mapping
from normalized positions to original positions.
Args:
preserve_spaces: If True, preserve spaces in the normalized text (for phrase matching)
Returns:
(normalized_text, position_map) where position_map[i] is the original
position of the i-th character in normalized_text
"""
# First normalize Unicode (composed vs decomposed)
normalized_unicode = unicodedata.normalize('NFKC', text)
# For phrase matching, convert zero-width chars to spaces instead of stripping
if preserve_spaces:
zero_width_chars = ['\u200B', '\u200C', '\u200D', '\uFEFF', '\u2060', '\u2061', '\u2062', '\u2063', '\u2064']
for zw_char in zero_width_chars:
normalized_unicode = normalized_unicode.replace(zw_char, ' ')
else:
# Strip zero-width characters
normalized_unicode = _strip_zero_width_chars(normalized_unicode)
normalized = []
position_map = []
for i, ch in enumerate(normalized_unicode):
# Check if character is alphanumeric (including Cyrillic)
if ch.isalnum():
# For phrase matching, preserve ASCII letters as-is (just lowercase)
# to allow English words in patterns to match
if preserve_spaces and ch.isascii() and ch.isalpha():
normalized.append(ch.lower())
else:
# Normalize this character (homoglyphs, Cyrillic, etc.)
normalized.append(_normalize_char(ch))
position_map.append(i)
elif preserve_spaces:
# For phrase matching, treat any whitespace or non-alphanumeric as word separator
if ch.isspace() or not ch.isalnum():
# Normalize to single space to allow patterns to match
if normalized and normalized[-1] != ' ': # Don't add consecutive spaces
normalized.append(' ')
position_map.append(i)
return "".join(normalized), position_map
def _check_profanity_substrings(normalized_text: str, profane_words: Set[str]) -> list[tuple[int, int]]:
"""
Check for profane words as substrings or subsequences in normalized text.
This catches cases like "хуй" in "хууй" (with extra characters).
Returns list of (start, end) positions where profanity is found.
"""
spans = []
normalized_lower = normalized_text.lower()
for word in profane_words:
word_lower = word.lower()
# First try exact substring match
start = 0
while True:
pos = normalized_lower.find(word_lower, start)
if pos == -1:
break
spans.append((pos, pos + len(word_lower)))
start = pos + 1
# Also check if profane word appears as a subsequence (allowing extra chars)
# This catches cases like "хуй" in "хууй" or "хU★уй" -> "хууй"
word_chars = list(word_lower)
text_chars = list(normalized_lower)
# Try to find the word as a subsequence
i = 0 # position in text
j = 0 # position in word
seq_start = None
while i < len(text_chars) and j < len(word_chars):
if text_chars[i] == word_chars[j]:
if seq_start is None:
seq_start = i
j += 1
if j == len(word_chars):
# Found the word as subsequence
seq_end = i + 1
# Only add if it's not already covered by exact match
if (seq_start, seq_end) not in spans:
spans.append((seq_start, seq_end))
# Reset to find next occurrence
seq_start = None
j = 0
# Continue from after the start position
i = seq_start + 1 if seq_start is not None else i + 1
continue
i += 1
return spans
def _find_profanity_spans_in_original(
normalized_text: str,
position_map: list[int],
original_length: int,
original_text: str
) -> list[tuple[int, int]]:
"""
Find profanity in normalized text and map the spans back to original text positions.
Uses both better_profanity library and substring matching for better detection.
Returns list of (start, end) tuples in original text coordinates.
"""
spans = []
if not normalized_text or not position_map:
return spans
# Check normalized text for profanity using better_profanity
censored = _profanity.censor(normalized_text, censor_char="\\*")
# Also check for profane words as substrings (to catch cases like "хуй" in "хууй" or "хуйня")
profane_words = _STATIC_TERMS
substring_spans = _check_profanity_substrings(normalized_text, profane_words)
# Combine spans from both methods
all_spans = set()
# From better_profanity censoring
i = 0
while i < len(censored):
if censored[i] == "*":
span_start = i
while i < len(censored) and censored[i] == "*":
i += 1
span_end = i
all_spans.add((span_start, span_end))
else:
i += 1
# From substring matching
for start, end in substring_spans:
all_spans.add((start, end))
# Map all spans to original positions
for span_start, span_end in all_spans:
if span_start < len(position_map):
orig_start = position_map[span_start]
# Find the end position - use the last mapped position in the span
if span_end > 0 and span_end <= len(position_map):
orig_end = position_map[span_end - 1] + 1
elif span_end > len(position_map):
orig_end = original_length
else:
orig_end = orig_start + 1
# Extend span to include any non-alphanumeric characters between
# the mapped positions in the original text
# Limit extension to prevent over-censoring (max 50 chars each direction)
max_extension = 50
extension_count = 0
# Extend backwards to include any preceding non-alphanumeric
while (orig_start > 0 and
not original_text[orig_start - 1].isalnum() and
extension_count < max_extension):
orig_start -= 1
extension_count += 1
extension_count = 0
# Extend forwards to include any following non-alphanumeric
while (orig_end < original_length and
not original_text[orig_end].isalnum() and
extension_count < max_extension):
orig_end += 1
extension_count += 1
spans.append((orig_start, min(orig_end, original_length)))
return spans
def _tokenize_with_spans(text: str) -> List[Tuple[int, int, str]]:
tokens: List[Tuple[int, int, str]] = []
start: int | None = None
@@ -241,8 +574,17 @@ def _rebuild_dictionary(force: bool = False) -> None:
profanity = Profanity()
profanity.load_censor_words()
# Remove whitelisted words from the default word list
try:
for word in _WHITELIST:
profanity.remove_censor_words([word])
except AttributeError:
# If remove_censor_words doesn't exist, we'll handle it in post-processing
pass
combined = set(_STATIC_TERMS)
combined.update(blocklist_list)
# Remove whitelisted words from our custom terms
combined -= _WHITELIST
if combined:
profanity.add_censor_words(list(combined))
@@ -251,18 +593,59 @@ def _rebuild_dictionary(force: bool = False) -> None:
def _apply_phrase_filters(text: str) -> str:
result = text
"""
Apply phrase patterns to text. Patterns are applied to normalized text
(without special characters) and then mapped back to original positions.
"""
# Normalize text for phrase matching (remove special chars but preserve spaces)
normalized_text, position_map = _extract_alphanumeric_with_mapping(text, preserve_spaces=True)
normalized_lower = normalized_text.lower()
result = list(text)
censored_positions = set()
# Apply phrase patterns to normalized text
for pattern in _PHRASE_PATTERNS:
while True:
match = pattern.search(result)
if not match:
break
result = result[:match.start()] + ("*" * (match.end() - match.start())) + result[match.end():]
for start, end in sorted(_find_fuzzy_phrase_spans(text, "generic"), reverse=True):
result = result[:start] + ("*" * (end - start)) + result[end:]
return result
for match in pattern.finditer(normalized_lower):
# Map back to original positions
norm_start = match.start()
norm_end = match.end()
if norm_start < len(position_map) and norm_end <= len(position_map):
orig_start = position_map[norm_start]
orig_end = position_map[norm_end - 1] + 1 if norm_end > 0 else orig_start + 1
# Extend to include special characters
while orig_start > 0 and not text[orig_start - 1].isalnum():
orig_start -= 1
while orig_end < len(text) and not text[orig_end].isalnum():
orig_end += 1
# Mark positions for censoring
for pos in range(orig_start, min(orig_end, len(result))):
censored_positions.add(pos)
# Apply fuzzy phrase spans
for start, end in sorted(_find_fuzzy_phrase_spans(normalized_lower, "generic"), reverse=True):
if start < len(position_map) and end <= len(position_map):
orig_start = position_map[start]
orig_end = position_map[end - 1] + 1 if end > 0 else orig_start + 1
# Extend to include special characters
while orig_start > 0 and not text[orig_start - 1].isalnum():
orig_start -= 1
while orig_end < len(text) and not text[orig_end].isalnum():
orig_end += 1
for pos in range(orig_start, min(orig_end, len(result))):
censored_positions.add(pos)
# Apply censoring
for pos in censored_positions:
if pos < len(result):
result[pos] = "*"
return "".join(result)
def censor_text(text: str) -> str:
@@ -271,20 +654,95 @@ def censor_text(text: str) -> str:
_rebuild_dictionary()
preprocessed = _apply_phrase_filters(text)
return _profanity.censor(preprocessed, censor_char="\\*")
# Normalize text for whitelist matching (to handle special characters)
normalized_for_whitelist, whitelist_position_map = _extract_alphanumeric_with_mapping(preprocessed)
normalized_for_whitelist_lower = normalized_for_whitelist.lower()
# Identify and protect whitelisted words (using normalized text)
whitelist_spans = []
for whitelist_word in _WHITELIST:
# Normalize whitelist word too
normalized_whitelist, _ = _extract_alphanumeric_with_mapping(whitelist_word)
normalized_whitelist_lower = normalized_whitelist.lower()
# Find in normalized text
pattern = re.compile(re.escape(normalized_whitelist_lower), re.IGNORECASE)
for match in pattern.finditer(normalized_for_whitelist_lower):
# Map back to original positions
if match.start() < len(whitelist_position_map) and match.end() <= len(whitelist_position_map):
orig_start = whitelist_position_map[match.start()]
orig_end = whitelist_position_map[match.end() - 1] + 1 if match.end() > 0 else orig_start + 1
# Extend to include any special characters
while orig_start > 0 and not preprocessed[orig_start - 1].isalnum():
orig_start -= 1
while orig_end < len(preprocessed) and not preprocessed[orig_end].isalnum():
orig_end += 1
whitelist_spans.append((orig_start, min(orig_end, len(preprocessed)), preprocessed[orig_start:orig_end]))
# Extract only alphanumeric characters and normalize homoglyphs
# This removes special characters, emojis, etc. that could be used to bypass the filter
normalized_text, position_map = _extract_alphanumeric_with_mapping(preprocessed)
normalized_lower = normalized_text.lower()
# Check profanity on normalized text (without special characters)
profanity_spans = _find_profanity_spans_in_original(
normalized_lower,
position_map,
len(preprocessed),
preprocessed
)
# Apply censoring to original text
result = list(preprocessed)
for start, end in profanity_spans:
# Check if this span overlaps with a whitelisted word
is_whitelisted = False
for wl_start, wl_end, _ in whitelist_spans:
# Check if spans overlap
if not (end <= wl_start or start >= wl_end):
is_whitelisted = True
break
if not is_whitelisted:
# Censor the entire span (including any special characters within it)
for pos in range(start, min(end, len(result))):
result[pos] = "*"
return "".join(result)
def contains_profanity(text: str) -> bool:
"""
Check if text contains profanity that would be censored.
Returns True if censor_text would actually censor anything.
"""
if not text:
return False
_rebuild_dictionary()
for pattern in _PHRASE_PATTERNS:
if pattern.search(text):
return True
if _find_fuzzy_phrase_spans(text, "generic"):
return True
return _profanity.contains_profanity(text)
# Use censor_text to check if anything would be censored
# This ensures consistency between contains_profanity and censor_text
censored = censor_text(text)
# Check if any characters were actually censored (changed to asterisks)
# by comparing the original text with the censored version
# We need to account for the fact that the original might already contain asterisks
if censored == text:
return False # No changes, so no profanity
# If the text changed, check if any non-asterisk characters were replaced
# by comparing character-by-character (excluding positions that were already asterisks)
for i, (orig_char, censored_char) in enumerate(zip(text, censored)):
if orig_char != "*" and censored_char == "*":
return True # A non-asterisk character was censored
# If censored is longer, check the extra characters
if len(censored) > len(text):
for i in range(len(text), len(censored)):
if censored[i] == "*":
return True
return False
def contains_sensitive_phrase(text: str) -> bool:
+4 -3
View File
@@ -4,16 +4,17 @@ import jwt
from typing import Optional, Any
import bcrypt
from constants import ACCESS_TOKEN_EXPIRE_HOURS, JWT_SECRET_KEY, JWT_ALGORITHM
from constants import MAX_TOKEN_LIFETIME_HOURS, JWT_SECRET_KEY, JWT_ALGORITHM
# JWT Helper Functions
def create_token(user_id: int, username: str, session_id: str) -> str:
expire = datetime.now() + timedelta(hours=ACCESS_TOKEN_EXPIRE_HOURS)
# Set a long expiration as safety net (actual expiration based on inactivity)
expire = datetime.now() + timedelta(hours=MAX_TOKEN_LIFETIME_HOURS)
payload = {
"user_id": user_id,
"username": username,
"session_id": session_id,
"exp": expire
"exp": int(expire.timestamp()) # JWT exp must be Unix timestamp (int)
}
return jwt.encode(payload, JWT_SECRET_KEY, algorithm=JWT_ALGORITHM)
+7
View File
@@ -0,0 +1,7 @@
from websocket.registry import WebSocketHandlerRegistry
# Note: handler_registry and websocket_handler are not imported here to avoid circular dependency
# Import them directly from websocket.handlers when needed
__all__ = ["WebSocketHandlerRegistry"]
+572
View File
@@ -0,0 +1,572 @@
from datetime import datetime
import json
import logging
import time
from typing import Any
from fastapi import HTTPException, WebSocket
from sqlalchemy.orm import Session
from websocket.registry import WebSocketHandlerRegistry
from routes.messaging import (
MessaggingSocketManager,
_send_message_internal,
get_messages,
edit_message,
delete_message,
add_reaction,
add_dm_reaction,
)
from models import (
User,
SendMessageRequest,
EditMessageRequest,
DMEnvelope,
ReactionRequest,
DMReactionRequest,
UpdateLog,
)
from security.audit import log_access, log_dm
logger = logging.getLogger("uvicorn.error")
# Create global registry instance
handler_registry = WebSocketHandlerRegistry()
# Create decorator alias
websocket_handler = handler_registry.register
def log(manager: MessaggingSocketManager, websocket: WebSocket, user: User | None, event: str, **extra: Any) -> None:
"""Log WebSocket event."""
ws_path = getattr(getattr(websocket, "url", None), "path", None)
if not ws_path and isinstance(getattr(websocket, "scope", None), dict):
ws_path = websocket.scope.get("path")
ws_path = ws_path or "unknown"
headers = {}
if isinstance(getattr(websocket, "scope", None), dict):
headers = {k.decode("latin1"): v.decode("latin1") for k, v in websocket.scope.get("headers", [])}
xff = headers.get("x-forwarded-for")
client_ip = xff.split(",")[0].strip() if xff else (websocket.client.host if websocket.client else None)
log_access(
"ws_event",
path=ws_path,
event=event,
user=user.username if user else None,
user_id=user.id if user else None,
ip=client_ip,
**extra,
)
@websocket_handler("getUpdates", authRequired=True)
async def getUpdates(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None:
"""Handle gap detection - client requests updates from a specific sequence number."""
last_seq = data.get("lastSeq", 0)
manager.last_seq_by_ws[websocket] = last_seq
current_seq = manager.sequence_numbers.get(user.id, 0)
# Query database for missed updates
missed_updates = []
if last_seq > 0 and last_seq < current_seq:
try:
# Get all updates between last_seq and current_seq
update_logs = db.query(UpdateLog).filter(
UpdateLog.user_id == user.id,
UpdateLog.sequence > last_seq,
UpdateLog.sequence <= current_seq
).order_by(UpdateLog.sequence.asc()).all()
# Each log entry contains a batch of updates with the same sequence number
for log_entry in update_logs:
updates = json.loads(log_entry.updates)
missed_updates.append({
"seq": log_entry.sequence,
"updates": updates
})
except Exception as e:
logger.error(f"Failed to retrieve missed updates: {e}")
# Send missed updates directly (not through return value)
for batch in missed_updates:
await websocket.send_json({
"type": "updates",
"seq": batch["seq"],
"updates": batch["updates"]
})
# Update the websocket's last sequence tracking
manager.last_seq_by_ws[websocket] = current_seq
log(manager, websocket, user, "getUpdates", last_seq=last_seq, current_seq=current_seq, missed_count=len(missed_updates))
return {
"status": "ok",
"lastSeq": current_seq,
"missedCount": len(missed_updates)
}
@websocket_handler("ping", authRequired=True)
async def ping(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None:
"""Handle ping - authenticate and set user online."""
# Set user online in DB
user.online = True
user.last_seen = datetime.now()
db.commit()
# Add to online users
manager.online_users.add(user.id)
# Broadcast status change
await manager.broadcast_status_change(user.id, True, user.last_seen.isoformat(), db)
log(manager, websocket, user, "ping")
return {"status": "success"}
@websocket_handler("getMessages", authRequired=True)
async def getMessages(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None:
"""Get all public chat messages."""
result = await get_messages(user, db)
log(manager, websocket, user, "getMessages")
return result
@websocket_handler("sendMessage", authRequired=True)
async def sendMessage(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None:
"""Send a public chat message."""
message_request: SendMessageRequest = SendMessageRequest.model_validate(data)
# Call internal function directly (rate limiting is handled at infrastructure level via Caddy)
response = await _send_message_internal(message_request, user, db, [])
await manager.broadcast({
"type": "newMessage",
"data": response["message"]
}, db)
log(manager, websocket, user, "sendMessage", message_id=response["message"]["id"])
return response
@websocket_handler("dmSend", authRequired=True)
async def dmSend(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None:
"""Send a direct message."""
payload = data
required = ["recipientId", "iv", "ciphertext", "salt", "iv2", "wrappedMk"]
for key in required:
if key not in payload:
raise HTTPException(status_code=400, detail=f"Missing {key}")
env = DMEnvelope(
sender_id=user.id,
recipient_id=int(payload["recipientId"]),
iv_b64=payload["iv"],
ciphertext_b64=payload["ciphertext"],
salt_b64=payload["salt"],
iv2_b64=payload["iv2"],
wrapped_mk_b64=payload["wrappedMk"],
reply_to_id=payload.get("replyToId") if isinstance(payload.get("replyToId"), int) else None,
)
db.add(env)
db.commit()
db.refresh(env)
payload_ws = {
"type": "dmNew",
"data": {
"id": env.id,
"senderId": env.sender_id,
"recipientId": env.recipient_id,
"iv": env.iv_b64,
"ciphertext": env.ciphertext_b64,
"salt": env.salt_b64,
"iv2": env.iv2_b64,
"wrappedMk": env.wrapped_mk_b64,
"timestamp": env.timestamp.isoformat(),
"replyToId": env.reply_to_id,
}
}
# Send push notification for DM
try:
from push_service import push_service
await push_service.send_dm_notification(db, env, user)
except Exception as e:
logger.error(f"Failed to send push notification for DM {env.id}: {e}")
await manager.send_update_to_user(env.recipient_id, "dmNew", payload_ws["data"], db)
await manager.send_update_to_user(env.sender_id, "dmNew", payload_ws["data"], db)
log(manager, websocket, user, "dmSend", dm_envelope_id=env.id, recipient_id=env.recipient_id)
log_dm(
"message_sent_ws",
dm_envelope_id=env.id,
sender_id=user.id,
sender_username=user.username,
recipient_id=env.recipient_id,
reply_to=env.reply_to_id,
)
return {"status": "ok", "id": env.id}
@websocket_handler("editMessage", authRequired=True)
async def editMessage(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None:
"""Edit a public chat message."""
from types import SimpleNamespace
message_id = data["message_id"]
request: EditMessageRequest = EditMessageRequest.model_validate(data)
# Create a dummy request object for the HTTP endpoint function
dummy_request = SimpleNamespace()
response = await edit_message(dummy_request, message_id, request, user, db)
await manager.broadcast({
"type": "messageEdited",
"data": response["message"]
}, db)
log(manager, websocket, user, "editMessage", message_id=message_id)
return response
@websocket_handler("dmEdit", authRequired=True)
async def dmEdit(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None:
"""Edit a direct message."""
payload = data
env_id = int(payload["id"])
env: DMEnvelope | None = db.query(DMEnvelope).filter(DMEnvelope.id == env_id).first()
if not env:
raise HTTPException(status_code=404, detail="DM not found")
if env.sender_id != user.id:
raise HTTPException(status_code=403, detail="You can only edit your own messages")
# Replace ciphertext and iv
env.iv_b64 = payload["iv"]
env.ciphertext_b64 = payload["ciphertext"]
env.iv2_b64 = payload["iv2"]
env.wrapped_mk_b64 = payload["wrappedMk"]
env.salt_b64 = payload["salt"]
db.commit()
db.refresh(env)
payload_ws = {
"type": "dmEdited",
"data": {
"id": env.id,
"senderId": env.sender_id,
"recipientId": env.recipient_id,
"iv": env.iv_b64,
"ciphertext": env.ciphertext_b64,
"iv2": env.iv2_b64,
"wrappedMk": env.wrapped_mk_b64,
"salt": env.salt_b64,
"timestamp": env.timestamp.isoformat(),
}
}
await manager.send_update_to_user(env.recipient_id, "dmEdited", payload_ws["data"], db)
await manager.send_update_to_user(env.sender_id, "dmEdited", payload_ws["data"], db)
log(manager, websocket, user, "dmEdit", dm_envelope_id=env.id)
log_dm(
"message_edited",
dm_envelope_id=env.id,
user_id=user.id,
username=user.username,
)
return {"status": "ok", "id": env.id}
@websocket_handler("dmDelete", authRequired=True)
async def dmDelete(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None:
"""Delete a direct message."""
payload = data
env_id = int(payload["id"])
env: DMEnvelope | None = db.query(DMEnvelope).filter(DMEnvelope.id == env_id).first()
if not env:
raise HTTPException(status_code=404, detail="DM not found")
if env.sender_id != user.id:
raise HTTPException(status_code=403, detail="You can only delete your own messages")
db.delete(env)
db.commit()
payload_ws = {
"type": "dmDeleted",
"data": {
"id": env_id,
"senderId": user.id,
"recipientId": payload.get("recipientId")
}
}
await manager.send_update_to_user(env.recipient_id, "dmDeleted", payload_ws["data"], db)
await manager.send_update_to_user(env.sender_id, "dmDeleted", payload_ws["data"], db)
log(manager, websocket, user, "dmDelete", dm_envelope_id=env_id)
log_dm(
"message_deleted",
dm_envelope_id=env_id,
user_id=user.id,
username=user.username,
recipient_id=env.recipient_id,
)
return {"status": "ok", "id": env_id}
@websocket_handler("deleteMessage", authRequired=True)
async def deleteMessage(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None:
"""Delete a public chat message."""
message_id = data["message_id"]
response = await delete_message(message_id, user, db)
await manager.broadcast({
"type": "messageDeleted",
"data": {"message_id": message_id}
}, db)
log(manager, websocket, user, "deleteMessage", message_id=message_id)
return response
@websocket_handler("addReaction", authRequired=True)
async def addReaction(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None:
"""Add or remove a reaction to a public chat message."""
reaction_request = ReactionRequest(
message_id=data["message_id"],
emoji=data["emoji"]
)
response = await add_reaction(reaction_request, user, db)
# Broadcast reaction update
await manager.broadcast({
"type": "reactionUpdate",
"data": {
"message_id": data["message_id"],
"emoji": data["emoji"],
"action": response["action"],
"user_id": user.id,
"username": user.username,
"reactions": response["reactions"]
}
}, db)
log(manager, websocket, user, "addReaction", message_id=data["message_id"], emoji=data["emoji"], action=response["action"])
return response
@websocket_handler("addDmReaction", authRequired=True)
async def addDmReaction(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None:
"""Add or remove a reaction to a direct message."""
reaction_request = DMReactionRequest(
dm_envelope_id=data["dm_envelope_id"],
emoji=data["emoji"]
)
response = await add_dm_reaction(reaction_request, user, db)
# Broadcast reaction update
await manager.broadcast({
"type": "dmReactionUpdate",
"data": {
"dm_envelope_id": data["dm_envelope_id"],
"emoji": data["emoji"],
"action": response["action"],
"user_id": user.id,
"username": user.username,
"reactions": response["reactions"]
}
}, db)
log(manager, websocket, user, "addDmReaction", dm_envelope_id=data["dm_envelope_id"], emoji=data["emoji"], action=response["action"])
return response
@websocket_handler("call_signaling", authRequired=True)
async def call_signaling(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None:
"""Forward WebRTC signaling between peers."""
payload = data or {}
to_user_id = int(payload.get("toUserId") or 0)
if not to_user_id:
raise HTTPException(status_code=400, detail="Missing toUserId")
# Ensure sender is set by the server
payload["fromUserId"] = user.id
payload["fromUsername"] = user.username
await manager.send_to_user(to_user_id, {
"type": "call_signaling",
"data": payload
})
log(manager, websocket, user, "call_signaling", to_user_id=to_user_id)
return {"status": "ok"}
@websocket_handler("call_video_toggle", authRequired=True)
async def call_video_toggle(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None:
"""Forward video toggle state between peers."""
payload = data or {}
to_user_id = int(payload.get("toUserId") or 0)
if not to_user_id:
raise HTTPException(status_code=400, detail="Missing toUserId")
await manager.send_update_to_user(to_user_id, "call_signaling", {
"type": "call_video_toggle",
"fromUserId": user.id,
"toUserId": to_user_id,
"data": {"enabled": payload.get("enabled", False)}
}, db)
log(manager, websocket, user, "call_video_toggle", to_user_id=to_user_id, enabled=payload.get("enabled", False))
return {"status": "ok"}
@websocket_handler("call_screen_share_toggle", authRequired=True)
async def call_screen_share_toggle(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None:
"""Forward screen share toggle state between peers."""
payload = data or {}
to_user_id = int(payload.get("toUserId") or 0)
if not to_user_id:
raise HTTPException(status_code=400, detail="Missing toUserId")
await manager.send_update_to_user(to_user_id, "call_signaling", {
"type": "call_screen_share_toggle",
"fromUserId": user.id,
"toUserId": to_user_id,
"data": {"enabled": payload.get("enabled", False)}
}, db)
log(manager, websocket, user, "call_screen_share_toggle", to_user_id=to_user_id, enabled=payload.get("enabled", False))
return {"status": "ok"}
@websocket_handler("subscribeStatus", authRequired=True)
async def subscribeStatus(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None:
"""Subscribe to status updates for a user."""
user_id_to_subscribe = int(data["userId"])
manager.ws_subscriptions[websocket].add(user_id_to_subscribe)
# Get current status of the user
target_user = db.query(User).filter(User.id == user_id_to_subscribe).first()
if target_user:
# Send current status directly (not through return value)
await websocket.send_json({
"type": "statusUpdate",
"data": {
"userId": user_id_to_subscribe,
"online": target_user.online,
"lastSeen": target_user.last_seen.isoformat() if target_user.last_seen else None
}
})
log(manager, websocket, user, "subscribeStatus", target_user_id=user_id_to_subscribe)
return {"status": "ok"}
else:
log(manager, websocket, user, "subscribeStatus_error", target_user_id=user_id_to_subscribe, error="User not found")
raise HTTPException(status_code=404, detail="User not found")
@websocket_handler("unsubscribeStatus", authRequired=True)
async def unsubscribeStatus(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None:
"""Unsubscribe from status updates for a user."""
user_id_to_unsubscribe = int(data["userId"])
manager.ws_subscriptions[websocket].discard(user_id_to_unsubscribe)
log(manager, websocket, user, "unsubscribeStatus", target_user_id=user_id_to_unsubscribe)
return {"status": "ok"}
@websocket_handler("typing", authRequired=True)
async def typing(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> None:
"""Handle typing indicator start for public chat."""
was_typing = manager.typing_state.get(user.id, False)
manager.typing_users[user.id] = time.time()
# Only send update if state changed (started typing)
if not was_typing:
manager.typing_state[user.id] = True
# Broadcast to all connected users
await manager.broadcast({
"type": "typing",
"data": {
"userId": user.id,
"username": user.username
}
}, db)
# No confirmation response - privacy protection
@websocket_handler("stopTyping", authRequired=True)
async def stopTyping(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> None:
"""Handle typing indicator stop for public chat."""
was_typing = manager.typing_state.get(user.id, False)
if user.id in manager.typing_users:
del manager.typing_users[user.id]
# Only send update if state changed (stopped typing)
if was_typing:
manager.typing_state[user.id] = False
# Broadcast to all connected users
await manager.broadcast({
"type": "stopTyping",
"data": {
"userId": user.id,
"username": user.username
}
}, db)
# No confirmation response - privacy protection
log(manager, websocket, user, "stopTyping")
@websocket_handler("dmTyping", authRequired=True)
async def dmTyping(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> None:
"""Handle typing indicator start for DM."""
recipient_id = int(data["recipientId"])
if user.id not in manager.dm_typing_users:
manager.dm_typing_users[user.id] = {}
if user.id not in manager.dm_typing_state:
manager.dm_typing_state[user.id] = {}
was_typing = manager.dm_typing_state[user.id].get(recipient_id, False)
manager.dm_typing_users[user.id][recipient_id] = time.time()
# Only send update if state changed (started typing)
if not was_typing:
manager.dm_typing_state[user.id][recipient_id] = True
# Send only to recipient
await manager.send_update_to_user(recipient_id, "dmTyping", {
"userId": user.id,
"username": user.username
}, db)
# No confirmation response - privacy protection
@websocket_handler("stopDmTyping", authRequired=True)
async def stopDmTyping(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> None:
"""Handle typing indicator stop for DM."""
recipient_id = int(data["recipientId"])
was_typing = False
if user.id in manager.dm_typing_state:
was_typing = manager.dm_typing_state[user.id].get(recipient_id, False)
if user.id in manager.dm_typing_users and recipient_id in manager.dm_typing_users[user.id]:
del manager.dm_typing_users[user.id][recipient_id]
if not manager.dm_typing_users[user.id]:
del manager.dm_typing_users[user.id]
# Only send update if state changed (stopped typing)
if was_typing:
if user.id in manager.dm_typing_state:
manager.dm_typing_state[user.id][recipient_id] = False
# Send only to recipient
await manager.send_update_to_user(recipient_id, "stopDmTyping", {
"userId": user.id,
"username": user.username
}, db)
# No confirmation response - privacy protection
+33
View File
@@ -0,0 +1,33 @@
from typing import Callable
class WebSocketHandlerRegistry:
"""Registry for WebSocket message handlers with authentication support."""
def __init__(self):
self._handlers: dict[str, tuple[Callable, bool]] = {}
def register(self, message_type: str, authRequired: bool = True):
"""Register a handler for a message type.
Args:
message_type: The WebSocket message type to handle
authRequired: If True, handler will receive authenticated User (not None) or raise 401
"""
def decorator(func: Callable):
self._handlers[message_type] = (func, authRequired)
return func
return decorator
def get_handler(self, message_type: str) -> tuple[Callable, bool] | None:
"""Get handler and authRequired flag for a message type.
Returns:
Tuple of (handler function, authRequired flag) or None if not found
"""
return self._handlers.get(message_type)
def get_all_types(self) -> list[str]:
"""Get all registered message types for debugging/logging."""
return list(self._handlers.keys())
+92
View File
@@ -0,0 +1,92 @@
from fastapi import HTTPException
from fastapi.security import HTTPAuthorizationCredentials
from sqlalchemy.orm import Session
from types import SimpleNamespace
from dependencies import get_current_user
from models import User
def extract_token_from_data(data: dict) -> str | None:
"""Extract authentication token from WebSocket message data.
Args:
data: WebSocket message data dictionary
Returns:
Token string or None if not present
"""
credentials = data.get("credentials")
if credentials and isinstance(credentials, dict):
return credentials.get("credentials")
return None
def get_current_user_from_token(token: str, db: Session) -> User | None:
"""Get user from authentication token.
Args:
token: JWT token string
db: Database session
Returns:
User object or None if token is invalid
"""
try:
# Ensure session is in a usable state before querying
try:
db.rollback()
except Exception:
pass
dummy_request = SimpleNamespace()
dummy_request.state = SimpleNamespace()
try:
from fastapi.security import HTTPBearer
security = HTTPBearer()
# We need to create credentials manually
credentials = HTTPAuthorizationCredentials(
scheme="Bearer",
credentials=token
)
return get_current_user(dummy_request, credentials, db)
except HTTPException:
return None
except Exception:
try:
db.rollback()
except Exception:
pass
return None
def authenticate_user(data: dict, db: Session, authRequired: bool) -> User | None:
"""Authenticate user from WebSocket message data.
Args:
data: WebSocket message data dictionary
db: Database session
authRequired: If True, raises 401 on missing/invalid token
Returns:
User object (guaranteed not None if authRequired=True) or None
Raises:
HTTPException: 401 if authRequired=True and token is missing/invalid
"""
token = extract_token_from_data(data)
if authRequired:
if not token:
raise HTTPException(status_code=401, detail="Missing credentials")
user = get_current_user_from_token(token, db)
if not user:
raise HTTPException(status_code=401, detail="Invalid credentials")
return user
else:
if token:
return get_current_user_from_token(token, db)
return None
+16
View File
@@ -0,0 +1,16 @@
import { API_BASE_URL } from "@/core/config";
import { getAuthHeaders } from "./user/auth";
import type { IceServersResponse } from "@/core/types";
/**
* Fetches ICE server configuration for WebRTC
*/
export async function iceServers(token: string): Promise<IceServersResponse> {
const res = await fetch(`${API_BASE_URL}/webrtc/ice`, {
headers: getAuthHeaders(token, true)
});
if (!res.ok) throw new Error("Failed to fetch ICE servers");
return await res.json();
}
+194
View File
@@ -0,0 +1,194 @@
import { API_BASE_URL } from "@/core/config";
import { getAuthHeaders } from "../user/auth";
import { ecdhSharedSecret, deriveWrappingKey } from "@/utils/crypto/asymmetric";
import { importAesGcmKey, aesGcmEncrypt, aesGcmDecrypt } from "@/utils/crypto/symmetric";
import { randomBytes } from "@/utils/crypto/kdf";
import { getCurrentKeys } from "../user/auth";
import { request } from "@/core/websocket";
import type { SendDMRequest, DmEnvelope, DMEditRequest, DmEncryptedJSON, BaseDmEnvelope, User } from "@/core/types";
import { b64, ub64 } from "@/utils/utils";
import { fetchUserPublicKey } from "../crypto/identity";
import { fetchUsers, searchUsers } from "../user/search";
export async function decrypt(envelope: DmEnvelope, senderPublicKeyB64: string): Promise<string> {
const keys = getCurrentKeys();
if (!keys) throw new Error("Keys not initialized");
// Obtain the key
const shared = ecdhSharedSecret(keys.privateKey, ub64(senderPublicKeyB64));
const wkRaw = await deriveWrappingKey(shared, ub64(envelope.salt), new Uint8Array([1]));
const wk = await importAesGcmKey(wkRaw);
const mk = await aesGcmDecrypt(wk, ub64(envelope.iv2), ub64(envelope.wrappedMk));
// Decrypt
const msg = await aesGcmDecrypt(await importAesGcmKey(mk), ub64(envelope.iv), ub64(envelope.ciphertext));
return new TextDecoder().decode(msg);
}
export async function fetchMessages(userId: number, token: string, limit: number = 50, beforeId?: number): Promise<{ messages: DmEnvelope[]; has_more: boolean }> {
let url = `${API_BASE_URL}/dm/history/${userId}?limit=${limit}`;
if (beforeId) {
url += `&before_id=${beforeId}`;
}
const response = await globalThis.fetch(url, {
headers: getAuthHeaders(token, true)
});
if (!response.ok) return { messages: [], has_more: false };
const data = await response.json();
return { messages: data.messages || [], has_more: data.has_more ?? false };
}
export async function send(recipientId: number, recipientPublicKeyB64: string, plaintext: string, authToken: string, replyToId?: number): Promise<void> {
const keys = getCurrentKeys();
if (!keys) throw new Error("Keys not initialized");
// Encryption key
const mk = randomBytes(32);
const wkSalt = randomBytes(16);
const shared = ecdhSharedSecret(keys.privateKey, ub64(recipientPublicKeyB64));
const wkRaw = await deriveWrappingKey(shared, wkSalt, new Uint8Array([1]));
const wk = await importAesGcmKey(wkRaw);
// Encrypt the message
const encMsg = await aesGcmEncrypt(await importAesGcmKey(mk), new TextEncoder().encode(plaintext));
const wrap = await aesGcmEncrypt(wk, mk);
const payload: SendDMRequest = {
recipientId: recipientId,
iv: b64(encMsg.iv),
ciphertext: b64(encMsg.ciphertext),
salt: b64(wkSalt),
iv2: b64(wrap.iv),
wrappedMk: b64(wrap.ciphertext)
};
if (replyToId) payload.replyToId = replyToId;
await request({
type: "dmSend",
credentials: {
scheme: "Bearer",
credentials: authToken
},
data: payload
});
}
export async function sendWithFiles(recipientId: number, recipientPublicKeyB64: string, plaintextJson: string, files: File[], token: string): Promise<void> {
const keys = getCurrentKeys();
if (!keys) throw new Error("Keys not initialized");
const mk = randomBytes(32);
const wkSalt = randomBytes(16);
const shared = await ecdhSharedSecret(keys.privateKey, ub64(recipientPublicKeyB64));
const wkRaw = await deriveWrappingKey(shared, wkSalt, new Uint8Array([1]));
const wk = await importAesGcmKey(wkRaw);
const wrap = await aesGcmEncrypt(wk, mk);
const form = new FormData();
const names: string[] = [];
function sliceBuffer(u8: Uint8Array): ArrayBuffer {
return (u8.buffer as ArrayBuffer).slice(u8.byteOffset, u8.byteOffset + u8.byteLength);
}
for (const f of files) {
// Encrypt file with same mk
const data = new Uint8Array(await f.arrayBuffer());
const enc = await aesGcmEncrypt(await importAesGcmKey(mk), data);
const blob = new Blob([sliceBuffer(enc.iv), sliceBuffer(enc.ciphertext)], { type: "application/octet-stream" });
const serverName = f.name; // server uses provided name
names.push(serverName);
form.append("files", new File([blob], serverName));
}
form.append("fileNames", JSON.stringify(names));
// Merge files metadata into plaintext JSON and encrypt
let obj: DmEncryptedJSON;
try {
obj = JSON.parse(plaintextJson);
} catch {
obj = { type: "text", data: { content: String(plaintextJson) } };
}
const encMsg = await aesGcmEncrypt(await importAesGcmKey(mk), new TextEncoder().encode(JSON.stringify(obj)));
form.append("dm_payload", JSON.stringify({
recipientId: recipientId,
iv: b64(encMsg.iv),
ciphertext: b64(encMsg.ciphertext),
salt: b64(wkSalt),
iv2: b64(wrap.iv),
wrappedMk: b64(wrap.ciphertext)
} satisfies BaseDmEnvelope));
await globalThis.fetch(`${API_BASE_URL}/dm/send`, {
method: "POST",
headers: getAuthHeaders(token, false),
body: form
});
}
export async function edit(id: number, recipientPublicKeyB64: string, newPlaintextJson: string, authToken: string): Promise<void> {
const keys = getCurrentKeys();
if (!keys) throw new Error("Keys not initialized");
// We cannot reuse the old mk safely without knowing it; generate a fresh mk and wrap
const mk = randomBytes(32);
const wkSalt = randomBytes(16);
const shared = await ecdhSharedSecret(keys.privateKey, ub64(recipientPublicKeyB64));
const wkRaw = await deriveWrappingKey(shared, wkSalt, new Uint8Array([1]));
const wk = await importAesGcmKey(wkRaw);
const encMsg = await aesGcmEncrypt(await importAesGcmKey(mk), new TextEncoder().encode(newPlaintextJson));
const wrap = await aesGcmEncrypt(wk, mk);
await request({
type: "dmEdit",
credentials: { scheme: "Bearer", credentials: authToken },
data: {
id,
iv: b64(encMsg.iv),
ciphertext: b64(encMsg.ciphertext),
iv2: b64(wrap.iv),
wrappedMk: b64(wrap.ciphertext),
salt: b64(wkSalt)
}
} as DMEditRequest);
}
export async function deleteMessage(id: number, recipientId: number, authToken: string): Promise<void> {
await request({
type: "dmDelete",
credentials: { scheme: "Bearer", credentials: authToken },
data: { id, recipientId }
});
}
export interface ConversationResponse {
user: User;
lastMessage: DmEnvelope;
unreadCount: number;
}
export async function conversations(token: string): Promise<ConversationResponse[]> {
const res = await fetch(`${API_BASE_URL}/dm/conversations`, {
headers: getAuthHeaders(token, true)
});
if (!res.ok) return [];
const data = await res.json();
return data.conversations || [];
}
/**
* Marks a DM as read
*/
export async function markRead(id: number, authToken: string): Promise<void> {
await request({
type: "dmMarkRead",
credentials: { scheme: "Bearer", credentials: authToken },
data: { id }
});
}
// Re-export user functions for convenience
export { fetchUsers, searchUsers, fetchUserPublicKey };
+99
View File
@@ -0,0 +1,99 @@
import { API_BASE_URL } from "@/core/config";
import { getAuthHeaders } from "../user/auth";
import type { Message, Messages, SendMessageRequest } from "@/core/types";
import { request } from "@/core/websocket";
/**
* Fetches public chat messages
*/
export async function fetchMessages(token: string, limit: number = 50, beforeId?: number): Promise<{ messages: Message[]; has_more: boolean }> {
let url = `${API_BASE_URL}/get_messages?limit=${limit}`;
if (beforeId) {
url += `&before_id=${beforeId}`;
}
const response = await globalThis.fetch(url, {
headers: getAuthHeaders(token, true)
});
if (!response.ok) return { messages: [], has_more: false };
const data: Messages & { has_more?: boolean } = await response.json();
return { messages: data.messages || [], has_more: data.has_more ?? false };
}
/**
* Sends a public chat message via WebSocket
*/
export async function send(content: string, replyToId: number | null, authToken: string): Promise<void> {
await request({
data: {
content: content.trim(),
reply_to_id: replyToId ?? null
},
credentials: {
scheme: "Bearer",
credentials: authToken
},
type: "sendMessage"
} satisfies SendMessageRequest);
}
/**
* Sends a public chat message with files via HTTP
*/
export async function sendWithFiles(
content: string,
replyToId: number | null,
files: File[],
authToken: string
): Promise<void> {
const form = new FormData();
form.append("payload", JSON.stringify({
content: content.trim(),
reply_to_id: replyToId ?? null
} satisfies SendMessageRequest["data"]));
for (const f of files) form.append("files", f, f.name);
const res = await globalThis.fetch(`${API_BASE_URL}/send_message`, {
method: "POST",
headers: getAuthHeaders(authToken, false),
body: form
});
if (!res.ok) {
const error = await res.text();
throw new Error(error || "Failed to send message with files");
}
}
/**
* Edits a public chat message
*/
export async function edit(messageId: number, newContent: string, authToken: string): Promise<void> {
const res = await globalThis.fetch(`${API_BASE_URL}/edit_message/${messageId}`, {
method: "PUT",
headers: getAuthHeaders(authToken, true),
body: JSON.stringify({ content: newContent })
});
if (!res.ok) throw new Error("Failed to edit message");
}
/**
* Deletes a public chat message
*/
export async function deleteMessage(messageId: number, authToken: string): Promise<void> {
const res = await globalThis.fetch(`${API_BASE_URL}/delete_message/${messageId}`, {
method: "DELETE",
headers: getAuthHeaders(authToken, true)
});
if (!res.ok) throw new Error("Failed to delete message");
}
/**
* Marks a message as read
*/
export async function markRead(messageId: number, authToken: string): Promise<void> {
const res = await globalThis.fetch(`${API_BASE_URL}/messages/mark_read`, {
method: "POST",
headers: getAuthHeaders(authToken, true),
body: JSON.stringify({ message_id: messageId })
});
if (!res.ok) throw new Error("Failed to mark message as read");
}
+37
View File
@@ -0,0 +1,37 @@
import { API_BASE_URL } from "@/core/config";
import { getAuthHeaders } from "../user/auth";
import type { BackupBlob } from "@/core/types";
/**
* Fetches the current user's backup blob
*/
export async function fetchBackupBlob(token: string): Promise<string | null> {
const headers = getAuthHeaders(token, true);
const res = await fetch(`${API_BASE_URL}/crypto/backup`, {
method: "GET",
headers
});
if (res.ok) {
const response: BackupBlob = await res.json();
return response.blob;
} else {
return null;
}
}
/**
* Uploads the current user's backup blob
*/
export async function uploadBackupBlob(blobJson: string, token: string): Promise<void> {
const payload: BackupBlob = { blob: blobJson }
const headers = getAuthHeaders(token, true);
const res = await fetch(`${API_BASE_URL}/crypto/backup`, {
method: "POST",
headers,
body: JSON.stringify(payload)
});
if (!res.ok) throw new Error("Failed to upload backup blob");
}
+45
View File
@@ -0,0 +1,45 @@
import { API_BASE_URL } from "@/core/config";
import { getAuthHeaders } from "../user/auth";
import type { UploadPublicKeyRequest } from "@/core/types";
import { b64, ub64 } from "@/utils/utils";
/**
* Fetches the current user's public key
*/
export async function fetchPublicKey(token: string): Promise<Uint8Array | null> {
const headers = getAuthHeaders(token, true);
const res = await fetch(`${API_BASE_URL}/crypto/public-key`, { method: "GET", headers });
if (!res.ok) return null;
const data = await res.json();
if (!data?.publicKey) return null;
return ub64(data.publicKey);
}
/**
* Uploads the current user's public key
*/
export async function uploadPublicKey(publicKey: Uint8Array, token: string): Promise<void> {
const payload: UploadPublicKeyRequest = {
publicKey: b64(publicKey)
}
const headers = getAuthHeaders(token, true);
const res = await fetch(`${API_BASE_URL}/crypto/public-key`, {
method: "POST",
headers,
body: JSON.stringify(payload)
});
if (!res.ok) throw new Error("Failed to upload public key");
}
/**
* Fetches another user's public key by user ID
*/
export async function fetchUserPublicKey(userId: number, token: string): Promise<string | null> {
const res = await fetch(`${API_BASE_URL}/crypto/public-key/of/${userId}`, { headers: getAuthHeaders(token, true) });
if (!res.ok) return null;
const data = await res.json();
return data.publicKey;
}
+14
View File
@@ -0,0 +1,14 @@
// Placeholder for Signal Protocol pre-key management
// Will be implemented when Signal Protocol is added
export async function upload(_bundle: unknown, _token: string): Promise<void> {
// TODO: Implement Signal Protocol pre-key upload
throw new Error("Not implemented yet");
}
export async function fetch(_userId: number, _token: string): Promise<unknown> {
// TODO: Implement Signal Protocol pre-key fetch
throw new Error("Not implemented yet");
}
+37 -33
View File
@@ -1,39 +1,43 @@
import { API_BASE_URL } from "@/core/config";
import { getAuthHeaders } from "./account";
import { getAuthHeaders } from "./user/auth";
/**
* Gets the URL for a normal (unencrypted) file
*/
export function getNormalFileUrl(filename: string): string {
return `${API_BASE_URL}/uploads/files/normal/${filename}`;
}
export const normal = {
/**
* Gets the URL for a normal (unencrypted) file
*/
url(filename: string): string {
return `${API_BASE_URL}/uploads/files/normal/${filename}`;
},
/**
* Gets the URL for an encrypted file
*/
export function getEncryptedFileUrl(filename: string): string {
return `${API_BASE_URL}/uploads/files/encrypted/${filename}`;
}
/**
* Fetches a normal file (unencrypted)
*/
async fetch(filename: string, token: string): Promise<Blob> {
const res = await fetch(this.url(filename), {
headers: getAuthHeaders(token, false)
});
if (!res.ok) throw new Error("Failed to fetch file");
return await res.blob();
}
};
/**
* Fetches a normal file (unencrypted)
*/
export async function fetchNormalFile(filename: string, token: string): Promise<Blob> {
const res = await fetch(getNormalFileUrl(filename), {
headers: getAuthHeaders(token, false)
});
if (!res.ok) throw new Error("Failed to fetch file");
return await res.blob();
}
export const encrypted = {
/**
* Gets the URL for an encrypted file
*/
url(filename: string): string {
return `${API_BASE_URL}/uploads/files/encrypted/${filename}`;
},
/**
* Fetches an encrypted file
*/
export async function fetchEncryptedFile(filename: string, token: string): Promise<Blob> {
const res = await fetch(getEncryptedFileUrl(filename), {
headers: getAuthHeaders(token, false)
});
if (!res.ok) throw new Error("Failed to fetch encrypted file");
return await res.blob();
}
/**
* Fetches an encrypted file
*/
async fetch(filename: string, token: string): Promise<Blob> {
const res = await fetch(this.url(filename), {
headers: getAuthHeaders(token, false)
});
if (!res.ok) throw new Error("Failed to fetch encrypted file");
return await res.blob();
}
};
+50
View File
@@ -0,0 +1,50 @@
import * as chatsGeneral from "./chats/general";
import * as chatsDm from "./chats/dm";
import * as userProfile from "./user/profile";
import * as userAuth from "./user/auth";
import * as userDevices from "./user/devices";
import * as userSearch from "./user/search";
import * as cryptoPrekeys from "./crypto/prekeys";
import * as cryptoIdentity from "./crypto/identity";
import * as cryptoBackup from "./crypto/backup";
import * as moderationBlocklist from "./moderation/blocklist";
import * as moderationUsers from "./moderation/users";
import * as callsModule from "./calls";
import * as filesModule from "./files";
import * as pushModule from "./push";
const api = {
chats: {
general: chatsGeneral,
dm: chatsDm
},
user: {
profile: userProfile,
auth: userAuth,
devices: userDevices,
search: userSearch
},
crypto: {
prekeys: cryptoPrekeys,
identity: cryptoIdentity,
backup: cryptoBackup
},
moderation: {
blocklist: moderationBlocklist,
users: moderationUsers
},
calls: callsModule,
files: filesModule,
push: pushModule
};
export default api;
export const chats = api.chats;
export const user = api.user;
export const crypto = api.crypto;
export const moderation = api.moderation;
export const calls = api.calls;
export const files = api.files;
export const push = api.push;
@@ -0,0 +1,55 @@
import { API_BASE_URL } from "@/core/config";
import { getAuthHeaders } from "../user/auth";
export interface BlocklistResponse {
words: string[];
}
export interface BlocklistUpdateRequest {
words: string[];
}
export interface BlocklistUpdateResponse {
added?: string[];
removed?: string[];
words: string[];
}
/**
* Fetches the current blocklist (admin only)
*/
export async function get(token: string): Promise<BlocklistResponse> {
const res = await fetch(`${API_BASE_URL}/moderation/blocklist`, {
headers: getAuthHeaders(token, true)
});
if (!res.ok) throw new Error("Failed to fetch blocklist");
return await res.json();
}
/**
* Adds words to the blocklist (admin only)
*/
export async function add(words: string[], token: string): Promise<BlocklistUpdateResponse> {
const res = await fetch(`${API_BASE_URL}/moderation/blocklist`, {
method: "POST",
headers: getAuthHeaders(token, true),
body: JSON.stringify({ words })
});
if (!res.ok) throw new Error("Failed to add to blocklist");
return await res.json();
}
/**
* Removes words from the blocklist (admin only)
*/
export async function remove(words: string[], token: string): Promise<BlocklistUpdateResponse> {
const res = await fetch(`${API_BASE_URL}/moderation/blocklist`, {
method: "DELETE",
headers: getAuthHeaders(token, true),
body: JSON.stringify({ words })
});
if (!res.ok) throw new Error("Failed to remove from blocklist");
return await res.json();
}
+89
View File
@@ -0,0 +1,89 @@
import { API_BASE_URL } from "@/core/config";
import { getAuthHeaders } from "../user/auth";
/**
* Toggles verification status for a user (owner only)
*/
export async function verify(userId: number, token: string): Promise<{verified: boolean} | null> {
try {
const response = await fetch(`${API_BASE_URL}/user/${userId}/verify`, {
method: 'POST',
headers: getAuthHeaders(token, true)
});
if (response.ok) {
return await response.json();
}
return null;
} catch (error) {
console.error('Error verifying user:', error);
return null;
}
}
/**
* Suspends a user account (admin only)
*/
export async function suspend(userId: number, reason: string, token: string): Promise<{status: string; message: string; reason: string} | null> {
try {
const response = await fetch(`${API_BASE_URL}/user/${userId}/suspend`, {
method: 'POST',
headers: getAuthHeaders(token, true),
body: JSON.stringify({ reason })
});
if (response.ok) {
return await response.json();
}
return null;
} catch (error) {
console.error('Error suspending user:', error);
return null;
}
}
/**
* Unsuspends a user account (admin only)
*/
export async function unsuspend(userId: number, token: string): Promise<{status: string; message: string} | null> {
try {
const response = await fetch(`${API_BASE_URL}/user/${userId}/unsuspend`, {
method: 'POST',
headers: getAuthHeaders(token, true)
});
if (response.ok) {
return await response.json();
}
return null;
} catch (error) {
console.error('Error unsuspending user:', error);
return null;
}
}
/**
* Deletes a user account (admin only)
*/
export async function deleteUser(userId: number, token: string): Promise<{status: string; message: string} | null> {
try {
const response = await fetch(`${API_BASE_URL}/user/${userId}/delete`, {
method: 'POST',
headers: getAuthHeaders(token, true)
});
if (response.ok) {
return await response.json();
}
return null;
} catch (error) {
console.error('Error deleting user:', error);
return null;
}
}
+34 -32
View File
@@ -1,5 +1,5 @@
import { API_BASE_URL } from "@/core/config";
import { getAuthHeaders } from "./account";
import { getAuthHeaders } from "./user/auth";
export interface PushSubscriptionRequest {
endpoint: string;
@@ -14,37 +14,39 @@ export interface PushSubscriptionResponse {
message: string;
}
/**
* Subscribes the current user to push notifications
*/
export async function subscribeToPush(
subscription: PushSubscriptionRequest,
token: string
): Promise<PushSubscriptionResponse> {
const res = await fetch(`${API_BASE_URL}/push/subscribe`, {
method: "POST",
headers: getAuthHeaders(token, true),
body: JSON.stringify(subscription)
});
if (!res.ok) {
const error = await res.json().catch(() => ({ detail: "Failed to subscribe to push notifications" }));
throw new Error(error.detail || "Failed to subscribe to push notifications");
}
return await res.json();
}
export const subscription = {
/**
* Subscribes the current user to push notifications
*/
async subscribe(
subscription: PushSubscriptionRequest,
token: string
): Promise<PushSubscriptionResponse> {
const res = await fetch(`${API_BASE_URL}/push/subscribe`, {
method: "POST",
headers: getAuthHeaders(token, true),
body: JSON.stringify(subscription)
});
if (!res.ok) {
const error = await res.json().catch(() => ({ detail: "Failed to subscribe to push notifications" }));
throw new Error(error.detail || "Failed to subscribe to push notifications");
}
return await res.json();
},
/**
* Unsubscribes the current user from push notifications
*/
export async function unsubscribeFromPush(token: string): Promise<PushSubscriptionResponse> {
const res = await fetch(`${API_BASE_URL}/push/unsubscribe`, {
method: "DELETE",
headers: getAuthHeaders(token, true)
});
if (!res.ok) {
const error = await res.json().catch(() => ({ detail: "Failed to unsubscribe from push notifications" }));
throw new Error(error.detail || "Failed to unsubscribe from push notifications");
/**
* Unsubscribes the current user from push notifications
*/
async unsubscribe(token: string): Promise<PushSubscriptionResponse> {
const res = await fetch(`${API_BASE_URL}/push/unsubscribe`, {
method: "DELETE",
headers: getAuthHeaders(token, true)
});
if (!res.ok) {
const error = await res.json().catch(() => ({ detail: "Failed to unsubscribe from push notifications" }));
throw new Error(error.detail || "Failed to unsubscribe from push notifications");
}
return await res.json();
}
return await res.json();
}
};
+221
View File
@@ -0,0 +1,221 @@
import { API_BASE_URL } from "@/core/config";
import type { LoginRequest, RegisterRequest, LoginResponse, Headers } from "@/core/types";
import { generateX25519KeyPair } from "@/utils/crypto/asymmetric";
import { encodeBlob, encryptBackupWithPassword, decryptBackupWithPassword, decodeBlob } from "@/utils/crypto/backup";
import { b64, ub64 } from "@/utils/utils";
import { hkdfExtractAndExpand } from "@/utils/crypto/kdf";
import { fetchPublicKey, uploadPublicKey } from "../crypto/identity";
import { fetchBackupBlob, uploadBackupBlob } from "../crypto/backup";
/**
* Generates authentication headers for API requests
* @param {string | null} token - Authentication token
* @param {boolean} json - Whether to include JSON content type header
* @returns {Headers} Headers object with authentication and content type
*/
export function getAuthHeaders(token: string | null, json: boolean = true): Headers {
const headers: Headers = {};
if (json) {
headers["Content-Type"] = "application/json";
}
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
return headers;
}
export interface CheckAuthResponse {
authenticated: boolean;
username: string;
admin: boolean;
}
export interface LogoutResponse {
status: string;
message: string;
}
export interface UserKeyPairMemory {
publicKey: Uint8Array;
privateKey: Uint8Array;
}
let currentPublicKey: Uint8Array | null = null;
let currentPrivateKey: Uint8Array | null = null;
export function getCurrentKeys(): UserKeyPairMemory | null {
if (currentPublicKey && currentPrivateKey) return { publicKey: currentPublicKey, privateKey: currentPrivateKey };
return null;
}
function saveKeys(
publicKey: Uint8Array<ArrayBufferLike>,
privateKey: Uint8Array<ArrayBufferLike>
) {
const encodedPublicKey = b64(publicKey);
const encodedPrivateKey = b64(privateKey);
localStorage.setItem("publicKey", encodedPublicKey);
localStorage.setItem("privateKey", encodedPrivateKey);
}
/**
* Checks if the current user is authenticated
*/
export async function checkAuth(token: string): Promise<CheckAuthResponse> {
const res = await fetch(`${API_BASE_URL}/check_auth`, {
headers: getAuthHeaders(token, true)
});
if (!res.ok) throw new Error("Failed to check auth");
return await res.json();
}
/**
* Logs in a user with username and password
*/
export async function login(request: LoginRequest): Promise<LoginResponse> {
const res = await fetch(`${API_BASE_URL}/login`, {
method: "POST",
headers: getAuthHeaders(null, true),
body: JSON.stringify(request)
});
if (!res.ok) {
const error = await res.json().catch(() => ({ detail: "Login failed" }));
throw new Error(error.detail || "Login failed");
}
return await res.json();
}
/**
* Registers a new user
*/
export async function register(request: RegisterRequest): Promise<LoginResponse> {
const res = await fetch(`${API_BASE_URL}/register`, {
method: "POST",
headers: getAuthHeaders(null, true),
body: JSON.stringify(request)
});
if (!res.ok) {
const error = await res.json().catch(() => ({ detail: "Registration failed" }));
throw new Error(error.detail || "Registration failed");
}
return await res.json();
}
/**
* Logs out the current user
*/
export async function logout(token: string): Promise<LogoutResponse> {
const res = await fetch(`${API_BASE_URL}/logout`, {
headers: getAuthHeaders(token, true)
});
if (!res.ok) throw new Error("Failed to logout");
return await res.json();
}
/**
* Derive a client-side authentication secret so the raw password never leaves the client.
* Uses PBKDF2 (via WebCrypto) + HKDF to produce a stable 32-byte key, then base64.
*/
export async function deriveAuthSecret(username: string, password: string): Promise<string> {
// Use per-user salt derived from username; in future we can fetch a server-provided salt
const salt = new TextEncoder().encode(`fromchat.user:${username}`);
// Derive 32 bytes using HKDF; PBKDF2 already used within importPassword
const derived = await hkdfExtractAndExpand(new TextEncoder().encode(password), salt, new TextEncoder().encode("auth-secret"), 32);
return b64(derived);
}
export async function ensureKeysOnLogin(password: string, token: string): Promise<UserKeyPairMemory> {
// Try to restore from backup
const blobJson = await fetchBackupBlob(token);
if (blobJson) {
const blob = decodeBlob(blobJson);
const bundle = await decryptBackupWithPassword(password, blob);
currentPrivateKey = bundle.privateKey;
// Ensure public key exists on server; if not, derive from private (not possible via libsafely), so keep previous
// In our simple scheme, we rely on server having the public key or we reupload generated one on first setup
const serverPub = await fetchPublicKey(token);
if (serverPub) {
currentPublicKey = serverPub;
} else {
// We don't have the corresponding public key from server; regenerate pair to resync
const pair = generateX25519KeyPair();
currentPublicKey = pair.publicKey;
currentPrivateKey = pair.privateKey;
await uploadPublicKey(currentPublicKey, token);
const newBlob = await encryptBackupWithPassword(password, { version: 1, privateKey: currentPrivateKey });
await uploadBackupBlob(encodeBlob(newBlob), token);
}
saveKeys(currentPublicKey!, currentPrivateKey!);
return {
publicKey: currentPublicKey!,
privateKey: currentPrivateKey!
};
}
// First-time setup: generate keys and upload
const pair = generateX25519KeyPair();
currentPublicKey = pair.publicKey;
currentPrivateKey = pair.privateKey;
await uploadPublicKey(currentPublicKey, token);
const encBlob = await encryptBackupWithPassword(password, { version: 1, privateKey: currentPrivateKey });
await uploadBackupBlob(encodeBlob(encBlob), token);
saveKeys(pair.publicKey, pair.privateKey);
return pair;
}
export function restoreKeys() {
currentPublicKey = ub64(localStorage.getItem("publicKey")!);
currentPrivateKey = ub64(localStorage.getItem("privateKey")!);
}
export function getAuthToken(): string | null {
return localStorage.getItem("authToken");
}
/**
* Changes the user's password
*/
export async function changePassword(
token: string,
username: string,
currentPassword: string,
newPassword: string,
logoutAllExceptCurrent: boolean
): Promise<void> {
const currentDerived = await deriveAuthSecret(username, currentPassword);
const newDerived = await deriveAuthSecret(username, newPassword);
const res = await fetch(`${API_BASE_URL}/change-password`, {
method: "POST",
headers: getAuthHeaders(token, true),
body: JSON.stringify({
currentPasswordDerived: currentDerived,
newPasswordDerived: newDerived,
logoutAllExceptCurrent
})
});
if (!res.ok) throw new Error("Failed to change password");
}
/**
* Deletes the current user's account
*/
export async function deleteAccount(token: string): Promise<{ status: string; message: string }> {
const res = await fetch(`${API_BASE_URL}/account/delete`, {
method: "POST",
headers: getAuthHeaders(token, true)
});
if (!res.ok) {
const error = await res.json().catch(() => ({ detail: "Failed to delete account" }));
throw new Error(error.detail || "Failed to delete account");
}
return await res.json();
}
+37
View File
@@ -0,0 +1,37 @@
import { API_BASE_URL } from "@/core/config";
import { getAuthHeaders } from "./auth";
export interface DeviceInfo {
session_id: string;
device_name?: string;
device_type?: string;
os_name?: string;
os_version?: string;
browser_name?: string;
browser_version?: string;
brand?: string;
model?: string;
created_at?: string;
last_seen?: string;
revoked?: boolean;
current?: boolean;
}
export async function list(token: string): Promise<DeviceInfo[]> {
const res = await fetch(`${API_BASE_URL}/devices`, { headers: getAuthHeaders(token, true) });
if (!res.ok) throw new Error("Failed to fetch devices");
const data = await res.json();
return data.devices as DeviceInfo[];
}
export async function revoke(token: string, sessionId: string): Promise<void> {
const res = await fetch(`${API_BASE_URL}/devices/${sessionId}`, { method: "DELETE", headers: getAuthHeaders(token, true) });
if (!res.ok) throw new Error("Failed to revoke device");
}
export async function revokeAll(token: string): Promise<void> {
const res = await fetch(`${API_BASE_URL}/devices/logout-all`, { method: "POST", headers: getAuthHeaders(token, true) });
if (!res.ok) throw new Error("Failed to logout all devices");
}
+191
View File
@@ -0,0 +1,191 @@
import { getAuthHeaders } from "./auth";
import { API_BASE_URL } from "@/core/config";
import type { UserProfile } from "@/core/types";
export interface ProfileData {
profile_picture?: string;
username?: string;
display_name?: string;
description?: string;
}
export interface UploadResponse {
profile_picture_url: string;
}
/**
* Loads user profile data from the server
*/
export async function get(token: string): Promise<ProfileData | null> {
try {
const response = await fetch(`${API_BASE_URL}/user/profile`, {
headers: getAuthHeaders(token, true)
});
if (response.ok) {
const data = await response.json();
// Map backend fields to frontend fields
return {
profile_picture: data.profile_picture,
username: data.username,
display_name: data.display_name,
description: data.bio
};
}
return null;
} catch (error) {
console.error('Error loading profile:', error);
return null;
}
}
/**
* Uploads a profile picture to the server
*/
export async function uploadPicture(token: string, file: Blob): Promise<UploadResponse | null> {
try {
const formData = new FormData();
formData.append('profile_picture', file, 'profile_picture.jpg');
const response = await fetch(`${API_BASE_URL}/upload-profile-picture`, {
method: 'POST',
body: formData,
headers: getAuthHeaders(token, false)
});
if (response.ok) {
return await response.json();
}
return null;
} catch (error) {
console.error('Upload error:', error);
return null;
}
}
/**
* Updates user profile information
*/
export async function update(token: string, data: Partial<ProfileData>): Promise<boolean> {
try {
// Map frontend fields to backend fields
const backendData = {
username: data.username,
display_name: data.display_name,
description: data.description
};
const response = await fetch(`${API_BASE_URL}/user/profile`, {
method: 'PUT',
headers: {
...getAuthHeaders(token, true),
'Content-Type': 'application/json'
},
body: JSON.stringify(backendData)
});
return response.ok;
} catch (error) {
console.error('Error updating profile:', error);
return false;
}
}
/**
* Updates user bio
*/
export async function updateBio(token: string, bio: string): Promise<boolean> {
try {
const response = await fetch(`${API_BASE_URL}/user/bio`, {
method: 'PUT',
headers: getAuthHeaders(token, true),
body: JSON.stringify({ bio })
});
return response.ok;
} catch (error) {
console.error('Error updating bio:', error);
return false;
}
}
/**
* Fetches user profile data by username
*/
export async function fetchByUsername(token: string, username: string): Promise<UserProfile | null> {
try {
const response = await fetch(`${API_BASE_URL}/user/${username}`, {
headers: getAuthHeaders(token, true)
});
if (response.ok) {
return await response.json();
}
return null;
} catch (error) {
console.error('Error fetching user profile:', error);
return null;
}
}
/**
* Fetches user profile data by user ID
*/
export async function fetchById(token: string, userId: number): Promise<UserProfile | null> {
try {
const response = await fetch(`${API_BASE_URL}/user/id/${userId}`, {
headers: getAuthHeaders(token, true)
});
if (response.ok) {
return await response.json();
}
return null;
} catch (error) {
console.error('Error fetching user profile by ID:', error);
return null;
}
}
/**
* In-memory cache for user similarity results
* Key: userId, Value: similarity result
*/
const similarityCache = new Map<number, {isSimilar: boolean, similarTo?: string} | null>();
/**
* Checks if a user is similar to any verified user
* Results are cached in memory to avoid redundant API calls
*/
export async function checkSimilarity(userId: number, token: string): Promise<{isSimilar: boolean, similarTo?: string} | null> {
// Check cache first
if (similarityCache.has(userId)) {
return similarityCache.get(userId) ?? null;
}
try {
const response = await fetch(`${API_BASE_URL}/user/check-similarity/${userId}`, {
headers: getAuthHeaders(token, true)
});
let result: {isSimilar: boolean, similarTo?: string} | null = null;
if (response.ok) {
result = await response.json();
}
// Cache the result (even if null/error)
similarityCache.set(userId, result);
return result;
} catch (error) {
console.error('Error checking user similarity:', error);
const result: null = null;
// Cache null result to avoid retrying on errors
similarityCache.set(userId, result);
return result;
}
}
+40
View File
@@ -0,0 +1,40 @@
import { API_BASE_URL } from "@/core/config";
import { getAuthHeaders } from "./auth";
import type { User } from "@/core/types";
/**
* Fetches a list of all users (excluding current user)
*/
export async function fetchUsers(token: string): Promise<User[]> {
const res = await fetch(`${API_BASE_URL}/users`, { headers: getAuthHeaders(token, true) });
if (!res.ok) return [];
const data = await res.json();
return data.users || [];
}
/**
* Searches for users by username query
*/
export async function searchUsers(query: string, token: string): Promise<User[]> {
if (query.length < 2) return [];
const res = await fetch(`${API_BASE_URL}/users/search?q=${encodeURIComponent(query)}`, {
headers: getAuthHeaders(token, true)
});
if (!res.ok) return [];
const data = await res.json();
return data.users || [];
}
/**
* Fetches a user by ID
*/
export async function get(userId: number, token: string): Promise<User | null> {
const res = await fetch(`${API_BASE_URL}/users/${userId}`, {
headers: getAuthHeaders(token, true)
});
if (!res.ok) return null;
return await res.json();
}
+4 -4
View File
@@ -2,7 +2,7 @@ import { importAesGcmKey, aesGcmEncrypt, aesGcmDecrypt } from "@/utils/crypto/sy
import { randomBytes } from "@/utils/crypto/kdf";
import { b64, ub64 } from "@/utils/utils";
import { ecdhSharedSecret, deriveWrappingKey } from "@/utils/crypto/asymmetric";
import { getCurrentKeys } from "@/core/api/account";
import api from "@/core/api";
import type { WrappedSessionKeyPayload } from "@/core/types";
export interface CallSessionKey {
@@ -186,7 +186,7 @@ const CALL_INFO = new Uint8Array([2]);
* @returns Promise that resolves to the wrapped session key payload
*/
export async function wrapCallSessionKeyForRecipient(recipientPublicKeyB64: string, sessionKey: Uint8Array): Promise<WrappedSessionKeyPayload> {
const keys = getCurrentKeys();
const keys = api.user.auth.getCurrentKeys();
if (!keys) throw new Error("Keys not initialized");
const salt = randomBytes(16);
@@ -209,7 +209,7 @@ export async function createSharedSecretAndDeriveSessionKey(
sessionKeyHash: string,
isInitiator: boolean
): Promise<CallSessionKey> {
const keys = getCurrentKeys();
const keys = api.user.auth.getCurrentKeys();
if (!keys) throw new Error("Keys not initialized");
// Create shared secret using ECDH
@@ -226,7 +226,7 @@ export async function createSharedSecretAndDeriveSessionKey(
* @returns Promise that resolves to the unwrapped session key
*/
export async function unwrapCallSessionKeyFromSender(senderPublicKeyB64: string, payload: WrappedSessionKeyPayload): Promise<Uint8Array> {
const keys = getCurrentKeys();
const keys = api.user.auth.getCurrentKeys();
if (!keys) throw new Error("Keys not initialized");
const salt = ub64(payload.salt);
+6 -8
View File
@@ -1,9 +1,7 @@
import { getAuthToken } from "@/core/api/account";
import api from "@/core/api";
import type { CallSignalingMessage, WrappedSessionKeyPayload } from "@/core/types";
import { getIceServers as fetchIceServers } from "@/core/api/webrtc";
import { request } from "@/core/websocket";
import { wrapCallSessionKeyForRecipient, unwrapCallSessionKeyFromSender, rotateCallSessionKey } from "./encryption";
import { fetchUserPublicKey } from "@/core/api/dm";
import { importAesGcmKey } from "@/utils/crypto/symmetric";
import E2EEWorker from "./e2eeWorker?worker";
import { delay } from "@/utils/utils";
@@ -100,9 +98,9 @@ export class WebRTCCall {
*/
private async getIceServers(): Promise<RTCIceServer[]> {
try {
const token = getAuthToken();
const token = api.user.auth.getAuthToken();
if (!token) throw new Error("No auth token");
const data = await fetchIceServers(token);
const data = await api.calls.iceServers(token);
return data.iceServers || [];
} catch (error) {
console.warn("Failed to fetch ICE servers:", error);
@@ -774,7 +772,7 @@ async function sendSignalingMessage(message: CallSignalingMessage) {
type: "call_signaling",
credentials: {
scheme: "Bearer",
credentials: getAuthToken()!
credentials: api.user.auth.getAuthToken()!
},
data: message
});
@@ -857,7 +855,7 @@ export async function sendCallSessionKey(userId: number, sessionKeyHash: string)
export async function sendWrappedCallSessionKey(userId: number, sessionKey: Uint8Array, sessionKeyHash: string): Promise<void> {
try {
const recipientPublicKey = await fetchUserPublicKey(userId, getAuthToken()!);
const recipientPublicKey = await api.chats.dm.fetchUserPublicKey(userId, api.user.auth.getAuthToken()!);
if (!recipientPublicKey) {
console.warn("No recipient public key for", userId);
return;
@@ -892,7 +890,7 @@ export async function receiveWrappedSessionKey(
sessionKeyHash?: string
): Promise<void> {
try {
const senderPublicKey = await fetchUserPublicKey(fromUserId, getAuthToken()!);
const senderPublicKey = await api.chats.dm.fetchUserPublicKey(fromUserId, api.user.auth.getAuthToken()!);
if (!senderPublicKey) {
console.error("Failed to get sender public key");
return;
+2 -2
View File
@@ -1,5 +1,5 @@
import { useState, useEffect } from "react";
import { checkUserSimilarity } from "@/core/api/account/profile";
import api from "@/core/api";
import { useUserStore } from "@/state/user";
import { MaterialIcon } from "@/utils/material";
@@ -18,7 +18,7 @@ export function StatusBadge({ verified, userId, size = "small" }: StatusBadgePro
// Check similarity for unverified users
useEffect(() => {
if (!verified && userId && user.authToken) {
checkUserSimilarity(userId, user.authToken)
api.user.profile.checkSimilarity(userId, user.authToken)
.then(result => {
setIsSimilarToVerified(result?.isSimilar || false);
})
@@ -1,5 +1,5 @@
import { useState } from "react";
import { verifyUser } from "@/core/api/account/profile";
import api from "@/core/api";
import { useUserStore } from "@/state/user";
import { MaterialButton } from "@/utils/material";
@@ -23,7 +23,7 @@ export function VerifyButton({ userId, verified, onVerificationChange }: VerifyB
setIsVerifying(true);
try {
const result = await verifyUser(userId, user.authToken);
const result = await api.moderation.users.verify(userId, user.authToken);
if (result) {
onVerificationChange?.(result.verified);
}
@@ -1,4 +1,4 @@
import { subscribeToPush } from "@/core/api/push";
import api from "@/core/api";
import { isElectron } from "@/core/electron/electron";
import { websocket } from "@/core/websocket";
import type { NewMessageWebSocketMessage, WebSocketMessage } from "@/core/types";
@@ -89,7 +89,7 @@ async function sendSubscriptionToServer(token: string): Promise<boolean> {
};
try {
await subscribeToPush(subscriptionData, token);
await api.push.subscription.subscribe(subscriptionData, token);
return true;
} catch (error) {
console.error("Failed to send subscription to server:", error);
+126
View File
@@ -0,0 +1,126 @@
/**
* @fileoverview Update Manager for Telegram-like update system
* @description Handles update sequence numbers, batching, and gap detection
* @author Cursor
* @version 1.0.0
*/
import { openDB, type IDBPDatabase } from "idb";
import type { WebSocketCredentials, WebSocketMessage } from "./types";
interface UpdateMessage<T = any> {
type: string;
data: T;
}
interface BatchedUpdatesMessage {
type: "updates";
seq: number;
updates: UpdateMessage[];
}
const DB_NAME = "fromchat-updates";
const DB_VERSION = 1;
const STORE_NAME = "lastSequence";
let db: IDBPDatabase | null = null;
/**
* Initialize IndexedDB for storing last sequence number
*/
async function initDB(): Promise<IDBPDatabase> {
if (db) return db;
db = await openDB(DB_NAME, DB_VERSION, {
upgrade(database) {
if (!database.objectStoreNames.contains(STORE_NAME)) {
database.createObjectStore(STORE_NAME);
}
}
});
return db;
}
/**
* Get the last received sequence number from IndexedDB
*/
export async function getLastSequence(): Promise<number> {
try {
return (await initDB())
.transaction(STORE_NAME, "readonly")
.objectStore(STORE_NAME)
.get("lastSeq") || 0;
} catch (error) {
console.error("Failed to get last sequence:", error);
return 0;
}
}
/**
* Store the last received sequence number in IndexedDB
*/
export async function setLastSequence(seq: number): Promise<void> {
try {
(await initDB()).transaction(STORE_NAME, "readwrite").objectStore(STORE_NAME).put(seq, "lastSeq");
} catch (error) {
console.error("Failed to set last sequence:", error);
}
}
/**
* Process a batched updates message
* @param message - The batched updates message from the server
* @param handler - Function to handle individual updates
* @param requestMissedFn - Optional function to request missed updates (for gap detection)
*/
export async function processBatchedUpdates(
message: BatchedUpdatesMessage,
handler: (update: UpdateMessage) => void,
requestMissedFn?: (lastSeq: number) => Promise<void>
): Promise<void> {
const { seq, updates } = message;
const lastSeq = await getLastSequence();
// Check for gap
if (seq !== lastSeq + 1 && lastSeq > 0) {
console.warn(`Update gap detected: expected ${lastSeq + 1}, got ${seq}`);
// Request missing updates if function provided
if (requestMissedFn) {
try {
await requestMissedFn(lastSeq);
} catch (error) {
console.error("Failed to request missed updates for gap:", error);
}
}
}
// Process all updates in the batch
for (const update of updates) {
handler(update);
}
// Update last sequence number
await setLastSequence(seq);
}
/**
* Request missed updates from the server
* @param lastSeq - The last sequence number we received
* @param requestFn - Function to send the request to the server
* @param credentials - Optional WebSocket credentials for authentication
*/
export async function requestMissedUpdates(
lastSeq: number,
requestFn: (request: WebSocketMessage<{ lastSeq: number }>) => Promise<void>,
credentials?: WebSocketCredentials
): Promise<void> {
if (lastSeq > 0) {
await requestFn({
type: "getUpdates",
data: { lastSeq },
credentials
});
}
}
+149 -32
View File
@@ -12,6 +12,8 @@ import { CallSignalingHandler } from "./calls/signaling";
import { onlineStatusManager } from "./onlineStatusManager";
import { typingManager } from "./typingManager";
import { useUserStore } from "@/state/user";
import { getLastSequence, processBatchedUpdates, requestMissedUpdates } from "./updateManager";
import { getAuthToken } from "@/core/api/user/auth";
/**
* Creates a new WebSocket connection to the chat server
@@ -148,55 +150,119 @@ async function reconnect(): Promise<void> {
*/
function setupEventHandlers(): void {
// Message handler
messageHandler = (e: MessageEvent) => {
messageHandler = async (e: MessageEvent) => {
try {
const response: WebSocketMessage<any> = JSON.parse(e.data);
// Handle batched updates
if (response.type === "updates" && "seq" in response && "updates" in response) {
// Create function to request missed updates with credentials
const token = getAuthToken();
const requestMissedFn = token ? async (lastSeq: number) => {
await requestMissedUpdates(lastSeq, async (req) => {
await request(req);
}, {
scheme: "Bearer",
credentials: token
});
} : undefined;
await processBatchedUpdates(response as any, (update) => {
// Route individual updates to appropriate handlers
handleUpdate(update);
}, requestMissedFn);
return;
}
// Handle call signaling messages
if (callSignalingHandler && response.type === "call_signaling" && response.data) {
callSignalingHandler.handleWebSocketMessage(response.data);
}
// Handle status and typing messages
if (response.type === "statusUpdate") {
onlineStatusManager.handleStatusUpdate(response as any);
} else if (response.type === "typing") {
typingManager.handleTyping(response as any);
} else if (response.type === "stopTyping") {
typingManager.handleStopTyping(response as any);
} else if (response.type === "dmTyping") {
typingManager.handleDmTyping(response as any);
} else if (response.type === "stopDmTyping") {
typingManager.handleStopDmTyping(response as any);
} else if (response.type === "suspended") {
// Handle account suspension
const { setSuspended } = useUserStore.getState();
const reason = response.data?.reason || "No reason provided";
setSuspended(reason);
// Close WebSocket connection
websocket.close();
} else if (response.type === "account_deleted") {
// Handle account deletion - silent logout
const { logout } = useUserStore.getState();
logout();
// Close WebSocket connection
websocket.close();
}
// Route message to global handler if set
if (globalMessageHandler) {
globalMessageHandler(response);
}
// Handle status and typing messages (these may come as immediate messages or in batches)
handleUpdate(response);
} catch (error) {
console.error("Error parsing WebSocket message:", error);
}
};
// Helper function to handle individual updates
function handleUpdate(response: WebSocketMessage<any>): void {
if (response.type === "statusUpdate") {
onlineStatusManager.handleStatusUpdate(response as any);
} else if (response.type === "typing") {
typingManager.handleTyping(response as any);
} else if (response.type === "stopTyping") {
typingManager.handleStopTyping(response as any);
} else if (response.type === "dmTyping") {
typingManager.handleDmTyping(response as any);
} else if (response.type === "stopDmTyping") {
typingManager.handleStopDmTyping(response as any);
} else if (response.type === "suspended") {
// Handle account suspension
const { setSuspended } = useUserStore.getState();
const reason = response.data?.reason || "No reason provided";
setSuspended(reason);
// Close WebSocket connection
websocket.close();
} else if (response.type === "account_deleted") {
// Handle account deletion - silent logout
const { logout } = useUserStore.getState();
logout();
// Close WebSocket connection
websocket.close();
}
// Route message to global handler if set
if (globalMessageHandler) {
globalMessageHandler(response);
}
}
websocket.addEventListener("message", messageHandler);
// Open handler
openHandler = () => {
openHandler = async () => {
reconnectAttempts = 0; // Reset on successful connection
isReconnecting = false;
// Authenticate by sending ping with credentials and request missed updates
try {
const token = getAuthToken();
if (token) {
const credentials = {
scheme: "Bearer",
credentials: token
};
// Send ping to authenticate and set user_by_ws on the server
try {
await request({
type: "ping",
credentials,
data: {}
});
} catch (error) {
console.error("Failed to send ping on reconnect:", error);
}
// Send last sequence number and request missed updates on reconnect
// Wait a bit for ping to complete authentication
await delay(100);
try {
const lastSeq = await getLastSequence();
if (lastSeq > 0) {
await requestMissedUpdates(lastSeq, async (req) => {
await request(req);
}, credentials);
}
} catch (error) {
console.error("Failed to request missed updates:", error);
}
}
} catch (error) {
console.error("Failed to authenticate on reconnect:", error);
}
};
websocket.addEventListener("open", openHandler);
@@ -276,4 +342,55 @@ export function request<Request, Response = any>(payload: WebSocketMessage<Reque
// Initialization
// --------------
/**
* Ensure WebSocket is connected and authenticated after login
* This should be called after successful authentication
*/
export async function ensureAuthenticated(): Promise<void> {
const token = getAuthToken();
if (!token) {
return;
}
// If WebSocket is not connected, wait for it to connect
if (websocket.readyState === WebSocket.CONNECTING) {
await new Promise<void>((resolve) => {
const checkConnection = () => {
if (websocket.readyState === WebSocket.OPEN) {
resolve();
} else if (websocket.readyState === WebSocket.CLOSED) {
// Connection failed, try to reconnect
reconnect().then(() => {
setTimeout(checkConnection, 100);
});
} else {
setTimeout(checkConnection, 100);
}
};
checkConnection();
});
} else if (websocket.readyState === WebSocket.CLOSED) {
// Reconnect if closed
await reconnect();
}
// If WebSocket is open, send ping to authenticate
if (websocket.readyState === WebSocket.OPEN) {
try {
const credentials = {
scheme: "Bearer",
credentials: token
};
await request({
type: "ping",
credentials,
data: {}
});
} catch (error) {
console.error("Failed to send ping after login:", error);
}
}
}
setupEventHandlers();
+12 -4
View File
@@ -5,13 +5,14 @@ import { useImmer } from "use-immer";
import type { LoginRequest } from "@/core/types";
import { useUserStore } from "@/state/user";
import { MaterialButton } from "@/utils/material";
import { ensureKeysOnLogin, deriveAuthSecret, login } from "@/core/api/account";
import api from "@/core/api";
import { AuthTextField, type AuthTextFieldHandle } from "./AuthTextField";
import { initialize, isSupported, startElectronReceiver, subscribe } from "@/core/push-notifications/push-notifications";
import { isElectron } from "@/core/electron/electron";
import type { Alert, AlertType } from "./Auth";
import { AuthHeader, AlertsContainer } from "./Auth";
import styles from "./auth.module.scss";
import { ensureAuthenticated } from "@/core/websocket";
const loginFieldVariants: Variants = {
initial: {
@@ -79,22 +80,29 @@ export function LoginForm({ onSwitchMode }: LoginFormProps) {
setIsLoading(true);
try {
const derived = await deriveAuthSecret(username, password);
const derived = await api.user.auth.deriveAuthSecret(username, password);
const request: LoginRequest = {
username: username,
password: derived
}
try {
const data = await login(request);
const data = await api.user.auth.login(request);
setUser(data.token, data.user);
try {
await ensureKeysOnLogin(password, data.token);
await api.user.auth.ensureKeysOnLogin(password, data.token);
} catch (e) {
console.error("Key setup failed:", e);
}
// Ensure WebSocket is connected and authenticated
try {
await ensureAuthenticated();
} catch (e) {
console.error("WebSocket authentication failed:", e);
}
navigate("/chat");
try {
+4 -4
View File
@@ -5,7 +5,7 @@ import { useImmer } from "use-immer";
import type { RegisterRequest } from "@/core/types";
import { useUserStore } from "@/state/user";
import { MaterialButton, MaterialIconButton } from "@/utils/material";
import { ensureKeysOnLogin, deriveAuthSecret, register } from "@/core/api/account";
import api from "@/core/api";
import { AuthTextField, type AuthTextFieldHandle } from "./AuthTextField";
import type { Alert, AlertType } from "./Auth";
import { AuthHeader, AlertsContainer } from "./Auth";
@@ -106,7 +106,7 @@ export function RegisterForm({ onSwitchMode }: RegisterFormProps) {
setIsLoading(true);
try {
const derived = await deriveAuthSecret(username, password);
const derived = await api.user.auth.deriveAuthSecret(username, password);
const request: RegisterRequest = {
display_name: displayName,
username: username,
@@ -115,11 +115,11 @@ export function RegisterForm({ onSwitchMode }: RegisterFormProps) {
}
try {
const data = await register(request);
const data = await api.user.auth.register(request);
setUser(data.token, data.user);
try {
await ensureKeysOnLogin(password, data.token);
await api.user.auth.ensureKeysOnLogin(password, data.token);
} catch (e) {
console.error("Key setup failed:", e);
}
@@ -2,24 +2,6 @@
@use "../../../css/material" as *;
@use "sass:color";
// Reply preview styles (shared with Message component)
.quote.contextualContent > .quoteInner {
display: flex;
flex-direction: column;
gap: 4px;
.replyUsername {
font-weight: 600;
color: $color-dark-on-surface;
font-size: 0.85rem;
}
.replyText {
overflow: hidden;
text-overflow: ellipsis;
}
}
.chatInputWrapper {
position: relative;
margin: 0 10px 10px 10px;
+43 -59
View File
@@ -2,27 +2,11 @@
@use "../../../css/material" as *;
@use "sass:color";
.quote.contextualContent > .quoteInner {
display: flex;
flex-direction: column;
gap: 4px;
.replyUsername {
font-weight: 600;
color: $color-dark-on-surface;
font-size: 0.85rem;
}
.replyText {
overflow: hidden;
text-overflow: ellipsis;
}
}
.message {
$status-indicator-size: 16px;
margin-bottom: 1rem;
margin-bottom: 10px;
max-width: 70%;
position: relative;
width: fit-content;
@@ -30,30 +14,8 @@
align-items: flex-start;
gap: 8px;
&.received {
.messageProfilePic {
width: 40px;
height: 40px;
flex-shrink: 0;
cursor: pointer;
transition: transform 0.2s ease;
&:hover {
transform: scale(1.05);
}
img {
width: 100%;
height: 100%;
border-radius: 50%;
object-fit: cover;
border: 2px solid $color-dark-outline;
}
}
}
.messageInner {
border-radius: 12px;
border-radius: 20px 20px 8px 8px; // Top corners rounded, bottom corners sharper
position: relative;
word-wrap: break-word;
overflow-wrap: anywhere;
@@ -72,6 +34,7 @@
display: flex;
align-items: center;
gap: 4px;
width: fit-content;
&:hover {
transform: scale(1.05);
@@ -92,9 +55,10 @@
}
}
.quote.replyPreview {
:global(.quote).replyPreview {
user-select: none;
margin: 10px;
margin: 5px;
border-radius: 16px;
}
.messageAttachments {
@@ -194,10 +158,31 @@
}
&.received {
.messageProfilePic {
width: 40px;
height: 40px;
flex-shrink: 0;
cursor: pointer;
transition: transform 0.2s ease;
align-self: flex-end;
&:hover {
transform: scale(1.05);
}
img {
width: 100%;
height: 100%;
border-radius: 50%;
object-fit: cover;
border: 2px solid $color-dark-outline;
}
}
.messageInner {
background: $color-dark-surface-container;
color: $color-dark-on-surface;
border-top-left-radius: 5px;
border-radius: 20px 20px 20px 8px; // Top-left: 5px, top-right: 20px, bottom: 8px
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
border: 1px solid rgba($color-dark-outline-variant, 0.4);
position: relative;
@@ -231,27 +216,27 @@
margin-left: auto;
flex-direction: row-reverse;
:global(.quote).replyPreview {
background: linear-gradient(135deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0.08));
border: 1px solid rgba(255, 255, 255, 0.2);
position: relative;
overflow: hidden;
box-shadow: 0 2px 8px rgba(147, 51, 234, 0.5);
:global(.quote-inner) {
position: relative;
z-index: 1;
}
}
.messageInner {
background: linear-gradient(135deg, #9333EA, #6366F1, #3B82F6);
color: $color-dark-on-primary;
border-top-right-radius: 5px;
background: linear-gradient(135deg, #9333EA, #6366F1, #2f68c5);
border-radius: 20px 20px 8px 20px; // Top-left: 20px, top-right: 5px, bottom: 8px
box-shadow: 0 0 20px rgba($color-dark-primary, 0.4);
border: 1px solid rgba($color-dark-primary, 0.5);
position: relative;
overflow: hidden;
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(135deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0.08));
pointer-events: none;
z-index: 0;
}
> * {
position: relative;
z-index: 1;
@@ -273,7 +258,6 @@
}
.messageTime {
color: $color-dark-on-primary;
font-weight: 500;
}
}
@@ -0,0 +1,19 @@
@use "../../../css/colors" as *;
@use "../../../css/material" as *;
:global(.quote).contextualContent > :global(.quote-inner) {
display: flex;
flex-direction: column;
gap: 4px;
.replyUsername {
font-weight: 600;
color: $color-dark-on-surface;
font-size: 0.85rem;
}
.replyText {
overflow: hidden;
text-overflow: ellipsis;
}
}
+21 -27
View File
@@ -1,14 +1,8 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { useUserStore } from "@/state/user";
import { useChatStore } from "@/state/chat";
import {
fetchUserPublicKey,
fetchDMHistory,
decryptDm,
sendDMViaWebSocket,
fetchDMConversations,
type DMConversationResponse
} from "@/core/api/dm";
import api from "@/core/api";
import type { ConversationResponse } from "@/core/api/chats/dm";
import type { User, Message, DmEncryptedJSON } from "@/core/types";
import { websocket } from "@/core/websocket";
@@ -58,11 +52,11 @@ export function useDM() {
try {
// Get public key
const publicKey = await fetchUserPublicKey(dmUser.id, user.authToken);
const publicKey = await api.chats.dm.fetchUserPublicKey(dmUser.id, user.authToken);
if (!publicKey) return;
// Get message history
const messages = await fetchDMHistory(dmUser.id, user.authToken, 50);
const { messages } = await api.chats.dm.fetchMessages(dmUser.id, user.authToken, 50);
if (messages.length === 0) return;
// Find last message
@@ -70,7 +64,7 @@ export function useDM() {
let lastPlaintext: string | null = null;
try {
lastPlaintext = (JSON.parse(await decryptDm(lastMessage, publicKey)) as DmEncryptedJSON).data.content;
lastPlaintext = (JSON.parse(await api.chats.dm.decrypt(lastMessage, publicKey)) as DmEncryptedJSON).data.content;
} catch (error) {
console.error("Failed to decrypt last message:", error);
}
@@ -107,11 +101,11 @@ export function useDM() {
usersLoadedRef.current = true;
setIsLoadingUsers(true);
try {
const conversations = await fetchDMConversations(user.authToken);
const conversations = await api.chats.dm.conversations(user.authToken);
// Process conversations and decrypt last messages
const dmUsersWithState: DMUser[] = await Promise.all(
conversations.map(async (conv: DMConversationResponse) => {
conversations.map(async (conv: ConversationResponse) => {
let lastMessageContent: string | undefined = undefined;
if (conv.lastMessage) {
@@ -121,10 +115,10 @@ export function useDM() {
? conv.lastMessage.recipientId
: conv.lastMessage.senderId;
const publicKey = await fetchUserPublicKey(otherUserId, user.authToken!);
const publicKey = await api.chats.dm.fetchUserPublicKey(otherUserId, user.authToken!);
if (publicKey) {
// Decrypt the last message
const decryptedJson = await decryptDm(conv.lastMessage, publicKey!);
const decryptedJson = await api.chats.dm.decrypt(conv.lastMessage, publicKey!);
const decryptedData = JSON.parse(decryptedJson) as DmEncryptedJSON;
lastMessageContent = formatDMMessageContent(decryptedData.data.content, conv.lastMessage.senderId, user.currentUser?.id!);
}
@@ -143,7 +137,7 @@ export function useDM() {
);
setDmUsersState(dmUsersWithState);
setDmUsers(conversations.map((conv: DMConversationResponse) => conv.user));
setDmUsers(conversations.map((conv: ConversationResponse) => conv.user));
} catch (error) {
console.error("Failed to load DM conversations:", error);
@@ -163,13 +157,13 @@ export function useDM() {
setIsLoadingHistory(true);
try {
const messages = await fetchDMHistory(userId, user.authToken, 50);
const { messages } = await api.chats.dm.fetchMessages(userId, user.authToken, 50);
const decryptedMessages: Message[] = [];
let maxIncomingId = 0;
for (const env of messages) {
try {
const text = await decryptDm(env, publicKey);
const text = await api.chats.dm.decrypt(env, publicKey);
const isAuthor = env.senderId !== userId;
const username = isAuthor ? (user.currentUser?.username || "Unknown") : "Other User";
@@ -214,7 +208,7 @@ export function useDM() {
if (!user.authToken) return;
try {
await sendDMViaWebSocket(recipientId, publicKey, content, user.authToken);
await api.chats.dm.send(recipientId, publicKey, content, user.authToken);
} catch (error) {
console.error("Failed to send DM:", error);
}
@@ -228,7 +222,7 @@ export function useDM() {
// Get public key if not already loaded
let publicKey = dmUser.publicKey;
if (!publicKey) {
publicKey = await fetchUserPublicKey(dmUser.id, user.authToken);
publicKey = await api.chats.dm.fetchUserPublicKey(dmUser.id, user.authToken);
if (!publicKey) return;
}
@@ -257,7 +251,7 @@ export function useDM() {
if (!user.authToken) return;
try {
const conversations = await fetchDMConversations(user.authToken);
const conversations = await api.chats.dm.conversations(user.authToken);
const userConversation = conversations.find(conv => conv.user.id === userId);
if (userConversation) {
@@ -270,10 +264,10 @@ export function useDM() {
? userConversation.lastMessage.recipientId
: userConversation.lastMessage.senderId;
const publicKey = await fetchUserPublicKey(otherUserId, user.authToken!);
const publicKey = await api.chats.dm.fetchUserPublicKey(otherUserId, user.authToken!);
if (publicKey) {
// Decrypt the last message
const decryptedJson = await decryptDm(userConversation.lastMessage, publicKey!);
const decryptedJson = await api.chats.dm.decrypt(userConversation.lastMessage, publicKey!);
const decryptedData = JSON.parse(decryptedJson) as DmEncryptedJSON;
lastMessageContent = formatDMMessageContent(decryptedData.data.content, userConversation.lastMessage.senderId, user.currentUser?.id!);
}
@@ -322,9 +316,9 @@ export function useDM() {
// Update unread count and last message preview
try {
const publicKey = await fetchUserPublicKey(otherUserId, user.authToken!);
const publicKey = await api.chats.dm.fetchUserPublicKey(otherUserId, user.authToken!);
if (publicKey) {
const decryptedJson = await decryptDm(envelope, publicKey);
const decryptedJson = await api.chats.dm.decrypt(envelope, publicKey);
const decryptedData = JSON.parse(decryptedJson) as DmEncryptedJSON;
const messageContent = decryptedData.data.content;
const formattedMessage = formatDMMessageContent(messageContent, senderId, user.currentUser.id);
@@ -352,9 +346,9 @@ export function useDM() {
}
const otherUserId = senderId === user.currentUser.id ? recipientId : senderId;
try {
const publicKey = await fetchUserPublicKey(otherUserId, user.authToken!);
const publicKey = await api.chats.dm.fetchUserPublicKey(otherUserId, user.authToken!);
if (publicKey) {
const decryptedJson = await decryptDm(envelope, publicKey);
const decryptedJson = await api.chats.dm.decrypt(envelope, publicKey);
const decryptedData = JSON.parse(decryptedJson) as DmEncryptedJSON;
const messageContent = decryptedData.data.content;
const formattedMessage = formatDMMessageContent(messageContent, senderId, user.currentUser.id);
+5 -4
View File
@@ -1,6 +1,7 @@
import { useState, useCallback, useEffect } from "react";
import { useUserStore } from "@/state/user";
import { loadProfile, updateProfile, uploadProfilePicture, type ProfileData } from "@/core/api/account/profile";
import api from "@/core/api";
import type { ProfileData } from "@/core/api/user/profile";
import { showSuccess, showError } from "@/utils/notification";
export default function useProfile() {
@@ -15,7 +16,7 @@ export default function useProfile() {
setIsLoading(true);
try {
const data = await loadProfile(user.authToken);
const data = await api.user.profile.get(user.authToken);
if (data) {
setProfileData(data);
}
@@ -33,7 +34,7 @@ export default function useProfile() {
setIsUpdating(true);
try {
const success = await updateProfile(user.authToken, data);
const success = await api.user.profile.update(user.authToken, data);
if (success) {
// Reload profile data to get updated information
await loadProfileData();
@@ -58,7 +59,7 @@ export default function useProfile() {
setIsUpdating(true);
try {
const result = await uploadProfilePicture(user.authToken, file);
const result = await api.user.profile.uploadPicture(user.authToken, file);
if (result) {
// Update profile data with new picture URL
setProfileData(prev => prev ? {
+3 -3
View File
@@ -6,7 +6,7 @@ import { useEffect, useRef } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import { useUserStore } from "@/state/user";
import { useProfileStore } from "@/state/profile";
import { fetchUserProfileById, fetchUserProfile } from "@/core/api/account/profile";
import api from "@/core/api";
import styles from "@/pages/chat/css/layout.module.scss";
export default function ChatPage() {
@@ -43,10 +43,10 @@ export default function ChatPage() {
if (profileInfo.userId) {
// Fetch by user ID
userProfile = await fetchUserProfileById(user.authToken, profileInfo.userId);
userProfile = await api.user.profile.fetchById(user.authToken, profileInfo.userId);
} else if (profileInfo.username) {
// Fetch by username
userProfile = await fetchUserProfile(user.authToken, profileInfo.username);
userProfile = await api.user.profile.fetchByUsername(user.authToken, profileInfo.username);
}
if (userProfile) {
+7 -7
View File
@@ -5,7 +5,7 @@ import type { ProfileDialogData } from "@/state/types";
import defaultAvatar from "@/images/default-avatar.png";
import { confirm } from "mdui/functions/confirm";
import { prompt } from "mdui/functions/prompt";
import { updateProfile, uploadProfilePicture, fetchUserProfileById, suspendUser, unsuspendUser, deleteUser } from "@/core/api/account/profile";
import api from "@/core/api";
import { RichTextArea } from "@/core/components/RichTextArea";
import { StatusBadge } from "@/core/components/StatusBadge";
import { VerifyButton } from "@/core/components/VerifyButton";
@@ -98,7 +98,7 @@ export function ProfileDialog() {
// If it's not the public chat and has a user ID, fetch fresh data
if (profileData.userId && profileData.username !== "Общий чат") {
const userProfile = await fetchUserProfileById(user.authToken, profileData.userId);
const userProfile = await api.user.profile.fetchById(user.authToken, profileData.userId);
if (userProfile) {
freshData = {
...userProfile,
@@ -285,7 +285,7 @@ export function ProfileDialog() {
}
if (Object.keys(updateData).length > 0) {
await updateProfile(user.authToken, updateData);
await api.user.profile.update(user.authToken, updateData);
}
// Update profile picture if changed
@@ -294,7 +294,7 @@ export function ProfileDialog() {
if (currentData.profilePicture.startsWith("data:")) {
const response = await fetch(currentData.profilePicture);
const blob = await response.blob();
await uploadProfilePicture(user.authToken, blob);
await api.user.profile.uploadPicture(user.authToken, blob);
}
}
@@ -351,7 +351,7 @@ export function ProfileDialog() {
});
if (reason) {
const result = await suspendUser(currentData.userId, reason, user.authToken!);
const result = await api.moderation.users.suspend(currentData.userId, reason, user.authToken!);
if (result) {
closeProfileDialog();
} else {
@@ -360,7 +360,7 @@ export function ProfileDialog() {
}
} else {
// Unsuspend user
const result = await unsuspendUser(currentData.userId, user.authToken!);
const result = await api.moderation.users.unsuspend(currentData.userId, user.authToken!);
if (result) {
closeProfileDialog();
} else {
@@ -383,7 +383,7 @@ export function ProfileDialog() {
cancelText: "Cancel"
});
const result = await deleteUser(currentData.userId, user.authToken!);
const result = await api.moderation.users.deleteUser(currentData.userId, user.authToken!);
if (result) {
closeProfileDialog();
@@ -2,8 +2,7 @@ import { useState, useEffect, useCallback, useMemo } from "react";
import { useUserStore } from "@/state/user";
import { useChatStore } from "@/state/chat";
import { useDM, type DMUser } from "@/pages/chat/hooks/useDM";
import { fetchMessages } from "@/core/api/messaging";
import { fetchUserPublicKey } from "@/core/api/dm";
import api from "@/core/api";
import { StatusBadge } from "@/core/components/StatusBadge";
import type { Message } from "@/core/types";
import { websocket } from "@/core/websocket";
@@ -52,7 +51,7 @@ export function UnifiedChatsList() {
if (!user.authToken) return;
try {
const messages = await fetchMessages(user.authToken, 1);
const { messages } = await api.chats.general.fetchMessages(user.authToken, 1);
if (messages?.length > 0) {
const lastMessage = messages[messages.length - 1];
setLastMessages({ general: lastMessage });
@@ -160,7 +159,7 @@ export function UnifiedChatsList() {
const authToken = useUserStore.getState().user.authToken;
if (!authToken) return;
const publicKey = await fetchUserPublicKey(dmConversation.id, authToken);
const publicKey = await api.chats.dm.fetchUserPublicKey(dmConversation.id, authToken);
if (!publicKey) {
console.error("Failed to get public key for user:", dmConversation.id);
return;
@@ -1,7 +1,7 @@
import { useState, useEffect, useRef } from "react";
import { useUserStore } from "@/state/user";
import { useChatStore } from "@/state/chat";
import { searchUsers, fetchUserPublicKey } from "@/core/api/dm";
import api from "@/core/api";
import { StatusBadge } from "@/core/components/StatusBadge";
import type { User } from "@/core/types";
import { onlineStatusManager } from "@/core/onlineStatusManager";
@@ -45,7 +45,7 @@ export function UsernameSearch({ containerRef, headerRef, bottomAppBarRef }: Use
const newTimeout = setTimeout(async () => {
if (user.authToken) {
try {
const users = await searchUsers(searchQuery, user.authToken);
const users = await api.user.search.searchUsers(searchQuery, user.authToken);
setSearchResults(users);
} catch (error) {
console.error("Search failed:", error);
@@ -118,7 +118,7 @@ export function UsernameSearch({ containerRef, headerRef, bottomAppBarRef }: Use
let publicKey = searchUser.publicKey;
if (!publicKey) {
const fetchedPublicKey = await fetchUserPublicKey(searchUser.id, user.authToken);
const fetchedPublicKey = await api.chats.dm.fetchUserPublicKey(searchUser.id, user.authToken);
publicKey = fetchedPublicKey;
}
@@ -1,6 +1,6 @@
import { MaterialList, MaterialListItem } from "@/utils/material";
import { useUserStore } from "@/state/user";
import { deleteAccount } from "@/core/api/account";
import api from "@/core/api";
import { confirm } from "mdui/functions/confirm";
import styles from "@/pages/chat/css/settings-dialog.module.scss";
@@ -23,7 +23,7 @@ export function AccountPanel({ onClose }: AccountPanelProps) {
cancelText: "Cancel"
});
await deleteAccount(authToken);
await api.user.auth.deleteAccount(authToken);
logout();
onClose();
} catch (error) {
@@ -2,7 +2,7 @@ import { useState } from "react";
import { StyledDialog } from "@/core/components/StyledDialog";
import type { DialogProps } from "@/core/types";
import { useUserStore } from "@/state/user";
import { changePassword } from "@/core/api/account";
import api from "@/core/api";
import { MaterialButton, MaterialIconButton, MaterialSwitch, MaterialTextField } from "@/utils/material";
import styles from "@/pages/chat/css/changePasswordDialog.module.scss";
@@ -29,7 +29,7 @@ export default function ChangePasswordDialog({ isOpen, onOpenChange }: DialogPro
if (!current || !next || next !== confirm) return;
setBusy(true);
try {
await changePassword(user.authToken, user.currentUser?.username, current, next, logoutAll);
await api.user.auth.changePassword(user.authToken, user.currentUser?.username, current, next, logoutAll);
setCurrent("");
setNext("");
setConfirm("");
@@ -2,7 +2,8 @@ import { useState, useEffect } from "react";
import { useImmer } from "use-immer";
import { MaterialList, MaterialListItem, MaterialButton, MaterialCircularProgress } from "@/utils/material";
import { useUserStore } from "@/state/user";
import { listDevices, revokeDevice, logoutAllOtherDevices, type DeviceInfo } from "@/core/api/account/devices";
import api from "@/core/api";
import type { DeviceInfo } from "@/core/api/user/devices";
import { confirm } from "mdui/functions/confirm";
import styles from "@/pages/chat/css/settings-dialog.module.scss";
@@ -24,7 +25,7 @@ export function DevicesPanel() {
setDevicesLoading(true);
try {
const deviceList = await listDevices(authToken);
const deviceList = await api.user.devices.list(authToken);
updateDevices(deviceList);
} catch (error) {
console.error("Failed to load devices:", error);
@@ -48,7 +49,7 @@ export function DevicesPanel() {
draft.add(sessionId);
});
await revokeDevice(authToken, sessionId);
await api.user.devices.revoke(authToken, sessionId);
await loadDevices();
} catch (error) {
if (error !== "cancelled") {
@@ -72,7 +73,7 @@ export function DevicesPanel() {
cancelText: "Cancel"
});
await logoutAllOtherDevices(authToken);
await api.user.devices.revokeAll(authToken);
await loadDevices();
} catch (error) {
if (error !== "cancelled") {
@@ -3,7 +3,7 @@ import { MaterialList, MaterialListItem, MaterialSwitch, type MDUISwitch } from
import { useUserStore } from "@/state/user";
import { initialize, subscribe, unsubscribe, isSupported } from "@/core/push-notifications/push-notifications";
import { isElectron } from "@/core/electron/electron";
import { unsubscribeFromPush } from "@/core/api/push";
import api from "@/core/api";
import styles from "@/pages/chat/css/settings-dialog.module.scss";
export function NotificationsPanel() {
@@ -73,7 +73,7 @@ export function NotificationsPanel() {
}
// Then unsubscribe from server
await unsubscribeFromPush(authToken);
await api.push.subscription.unsubscribe(authToken);
// After unsubscribing, permission is still granted but we're not subscribed
// So we keep the state as disabled (false)
@@ -7,6 +7,7 @@ import { useImmer } from "use-immer";
import { EmojiMenu } from "./EmojiMenu";
import { MaterialIcon, MaterialIconButton } from "@/utils/material";
import styles from "@/pages/chat/css/ChatInput.module.scss";
import replyPreviewStyles from "@/pages/chat/css/reply-preview.module.scss";
import { alert } from "mdui/functions/alert";
interface ChatInputWrapperProps {
@@ -161,9 +162,9 @@ export function ChatInputWrapper(
>
<div className={styles.contextualPreview}>
<MaterialIcon name="edit" />
<Quote className={`${styles.quote} ${styles.contextualContent}`} background="surfaceContainer">
<span className={styles.replyUsername}>{editingMessage!.username}</span>
<span className={styles.replyText}>{editingMessage!.content}</span>
<Quote className={`${replyPreviewStyles.contextualContent}`} background="surfaceContainer">
<span className={replyPreviewStyles.replyUsername}>{editingMessage!.username}</span>
<span className={replyPreviewStyles.replyText}>{editingMessage!.content}</span>
</Quote>
<MaterialIconButton icon="close" className={styles.replyCancel} onClick={onClearEdit}></MaterialIconButton>
</div>
@@ -181,9 +182,9 @@ export function ChatInputWrapper(
>
<div className={styles.contextualPreview}>
<MaterialIcon name="reply" />
<Quote className={`${styles.quote} ${styles.contextualContent}`} background="surfaceContainer">
<span className={styles.replyUsername}>{replyTo!.username}</span>
<span className={styles.replyText}>{replyTo!.content}</span>
<Quote className={`${replyPreviewStyles.contextualContent}`} background="surfaceContainer">
<span className={replyPreviewStyles.replyUsername}>{replyTo!.username}</span>
<span className={replyPreviewStyles.replyText}>{replyTo!.content}</span>
</Quote>
<MaterialIconButton icon="close" className={styles.replyCancel} onClick={onClearReply}></MaterialIconButton>
</div>
+14 -14
View File
@@ -5,12 +5,11 @@ import Quote from "@/core/components/Quote";
import { parse } from "marked";
import { escape as escapeHtml } from "he";
import { useEffect, useState, useRef, useMemo } from "react";
import { getCurrentKeys, getAuthHeaders } from "@/core/api/account";
import api from "@/core/api";
import { ecdhSharedSecret, deriveWrappingKey } from "@/utils/crypto/asymmetric";
import { importAesGcmKey, aesGcmDecrypt } from "@/utils/crypto/symmetric";
import { useUserStore } from "@/state/user";
import { useProfileStore } from "@/state/profile";
import { fetchUserProfileById, fetchUserProfile } from "@/core/api/account/profile";
import { StatusBadge } from "@/core/components/StatusBadge";
import { ub64 } from "@/utils/utils";
import { useImmer } from "use-immer";
@@ -18,6 +17,7 @@ import { createPortal } from "react-dom";
import { parseProfileLink } from "@/core/profileLinks";
import { MaterialCircularProgress, MaterialIconButton, MaterialList, MaterialListItem } from "@/utils/material";
import styles from "@/pages/chat/css/Message.module.scss";
import replyPreviewStyles from "@/pages/chat/css/reply-preview.module.scss";
interface MessageReactionsProps {
reactions?: Reaction[];
@@ -223,14 +223,14 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
// no-op decrypt indicator removed from UI
// Fetch encrypted file
const response = await fetch(file.path, {
headers: getAuthHeaders(user.authToken!)
headers: api.user.auth.getAuthHeaders(user.authToken!)
});
if (!response.ok) throw new Error("Failed to fetch file");
const encryptedData = await response.arrayBuffer();
// Get current user's keys
const keys = getCurrentKeys();
const keys = api.user.auth.getCurrentKeys();
if (!keys) throw new Error("Keys not initialized");
// Derive shared secret with the recipient's public key
@@ -343,7 +343,7 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
// Fetch with credentials/headers when not a blob URL
const response = await fetch(src, {
headers: user.authToken ? getAuthHeaders(user.authToken) : undefined,
headers: user.authToken ? api.user.auth.getAuthHeaders(user.authToken) : undefined,
credentials: "include"
});
if (!response.ok) throw new Error("Failed to download image");
@@ -381,7 +381,7 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
// If not decrypted or public file, fetch with credentials/headers
const response = await fetch(file.path, {
headers: user.authToken ? getAuthHeaders(user.authToken) : undefined,
headers: user.authToken ? api.user.auth.getAuthHeaders(user.authToken) : undefined,
credentials: "include"
});
if (!response.ok) throw new Error("Failed to download file");
@@ -405,7 +405,7 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
if (!user.authToken || !message.user_id) return;
try {
const userProfile = await fetchUserProfileById(user.authToken, message.user_id);
const userProfile = await api.user.profile.fetchById(user.authToken, message.user_id);
if (userProfile) {
setProfileDialog({
...userProfile,
@@ -434,9 +434,9 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
let userProfile;
if (profileLink.userId) {
userProfile = await fetchUserProfileById(user.authToken, profileLink.userId);
} else if (profileLink.username) {
userProfile = await fetchUserProfile(user.authToken, profileLink.username);
userProfile = await api.user.profile.fetchById(user.authToken, profileLink.userId);
} else if (profileLink.username) {
userProfile = await api.user.profile.fetchByUsername(user.authToken, profileLink.username);
}
if (userProfile) {
@@ -484,7 +484,7 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
{!isAuthor && !isDm && (
<div className={styles.messageProfilePic} onClick={handleProfileClick}>
<img
src={message.username?.startsWith("Deleted User #") ? defaultAvatar : (message.profile_picture || defaultAvatar)}
src={message.profile_picture || defaultAvatar}
alt={message.username}
onError={(e) => {
e.target.src = defaultAvatar;
@@ -508,9 +508,9 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
)}
{message.reply_to && (
<Quote className={`${styles.replyPreview} ${styles.contextualContent}`} background={isAuthor ? "primaryContainer" : "surfaceContainer"}>
<span className={styles.replyUsername}>{message.reply_to.username}</span>
<span className={styles.replyText}>{message.reply_to.content}</span>
<Quote className={`${styles.replyPreview} ${replyPreviewStyles.contextualContent}`} background={isAuthor ? "primaryContainer" : "surfaceContainer"}>
<span className={replyPreviewStyles.replyUsername}>{message.reply_to.username}</span>
<span className={replyPreviewStyles.replyText}>{message.reply_to.content}</span>
</Quote>
)}
@@ -58,6 +58,8 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
const [panelState, setPanelState] = useState<MessagePanelState | null>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
const previousMessageCountRef = useRef(0);
const messagesContainerRef = useRef<HTMLElement | null>(null);
const isLoadingMoreRef = useRef(false);
const [replyTo, setReplyTo] = useState<Message | null>(null);
const [replyToVisible, setReplyToVisible] = useState(Boolean(replyTo));
const [editMessage, setEditMessage] = useState<Message | null>(null);
@@ -92,6 +94,50 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
}
}, [editMessage]);
// Handle scroll detection for infinite loading
useEffect(() => {
if (!panel || !panelState) return;
const messagesContainer = document.getElementById("chat-messages");
if (!messagesContainer) return;
messagesContainerRef.current = messagesContainer;
const handleScroll = async () => {
if (!panel || !panelState || isLoadingMoreRef.current) return;
const container = messagesContainerRef.current;
if (!container) return;
// Check if scrolled to top (within 100px threshold)
if (container.scrollTop <= 100 && panelState.hasMoreMessages && !panelState.isLoadingMore) {
isLoadingMoreRef.current = true;
const previousScrollHeight = container.scrollHeight;
try {
await panel.loadMoreMessages();
// Preserve scroll position after loading
requestAnimationFrame(() => {
if (container) {
const newScrollHeight = container.scrollHeight;
container.scrollTop = newScrollHeight - previousScrollHeight;
}
isLoadingMoreRef.current = false;
});
} catch (error) {
console.error("Error loading more messages:", error);
isLoadingMoreRef.current = false;
}
}
};
messagesContainer.addEventListener("scroll", handleScroll);
return () => {
messagesContainer.removeEventListener("scroll", handleScroll);
};
}, [panel, panelState]);
// Handle panel state changes
useEffect(() => {
if (panel) {
@@ -280,31 +326,43 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
</div>
</div>
) : panelState && panel ? (
<ChatMessages
messages={panelState.messages}
isDm={panel.isDm()}
dmRecipientPublicKey={(panel as DMPanel).dmData?.publicKey}
onReplySelect={(message) => {
if (editMessage || editVisible) {
setPendingAction({ type: "reply", message: message });
setEditVisible(false); // onCloseEdit will apply pending
} else {
setReplyTo(message);
}
}}
onEditSelect={(message) => {
if (replyTo || replyToVisible) {
setPendingAction({ type: "edit", message: message });
setReplyToVisible(false); // onCloseReply will apply pending
} else {
setEditMessage(message);
}
}}
onDelete={(id) => panel.handleDeleteMessage(id)}
onRetryMessage={(id) => panel.retryMessage(id)}
>
<div ref={messagesEndRef} />
</ChatMessages>
<>
{panelState.isLoadingMore && (
<div style={{
display: "flex",
justifyContent: "center",
padding: "8px",
color: "var(--mdui-color-on-surface-variant)"
}}>
Загрузка...
</div>
)}
<ChatMessages
messages={panelState.messages}
isDm={panel.isDm()}
dmRecipientPublicKey={(panel as DMPanel).dmData?.publicKey}
onReplySelect={(message) => {
if (editMessage || editVisible) {
setPendingAction({ type: "reply", message: message });
setEditVisible(false); // onCloseEdit will apply pending
} else {
setReplyTo(message);
}
}}
onEditSelect={(message) => {
if (replyTo || replyToVisible) {
setPendingAction({ type: "edit", message: message });
setReplyToVisible(false); // onCloseReply will apply pending
} else {
setEditMessage(message);
}
}}
onDelete={(id) => panel.handleDeleteMessage(id)}
onRetryMessage={(id) => panel.retryMessage(id)}
>
<div ref={messagesEndRef} />
</ChatMessages>
</>
) : (
<div className={rightPanelStyles.chatMessages} id="chat-messages">
<div style={{
@@ -1,13 +1,5 @@
import { MessagePanel } from "./MessagePanel";
import {
fetchDMHistory,
decryptDm,
sendDMViaWebSocket,
sendDmWithFiles,
editDmEnvelope,
deleteDmEnvelope
} from "@/core/api/dm";
import { fetchUserProfileById } from "@/core/api/account/profile";
import api from "@/core/api";
import type { DmEncryptedJSON, DmEnvelope, DMWebSocketMessage, EncryptedMessageJson, Message } from "@/core/types";
import type { UserState, ProfileDialogData } from "@/state/types";
import { formatDMUsername } from "@/pages/chat/hooks/useDM";
@@ -63,7 +55,7 @@ export class DMPanel extends MessagePanel {
}
private async parseTextPayload(env: DmEnvelope, decryptedMessages: Message[]) {
const plaintext = await decryptDm(env, this.dmData!.publicKey);
const plaintext = await api.chats.dm.decrypt(env, this.dmData!.publicKey);
const username = formatDMUsername(
env.senderId,
env.recipientId,
@@ -111,7 +103,8 @@ export class DMPanel extends MessagePanel {
this.setLoading(true);
try {
const messages = await fetchDMHistory(this.dmData.userId, this.currentUser.authToken, 50);
const limit = this.calculateMessageLimit();
const { messages, has_more } = await api.chats.dm.fetchMessages(this.dmData.userId, this.currentUser.authToken, limit);
const decryptedMessages: Message[] = [];
let maxIncomingId = 0;
@@ -130,6 +123,7 @@ export class DMPanel extends MessagePanel {
this.clearMessages();
decryptedMessages.forEach(msg => this.addMessage(msg));
this.setHasMoreMessages(has_more);
// Update last read ID
if (maxIncomingId > 0) {
@@ -143,6 +137,50 @@ export class DMPanel extends MessagePanel {
}
}
async loadMoreMessages(): Promise<void> {
if (!this.currentUser.authToken || !this.dmData || !this.state.hasMoreMessages || this.state.isLoadingMore) return;
const messages = this.getMessages();
if (messages.length === 0) return;
const oldestMessage = messages[0];
const oldestEnvelope = oldestMessage.runtimeData?.dmEnvelope;
if (!oldestEnvelope) return;
this.setLoadingMore(true);
try {
const limit = this.calculateMessageLimit();
const { messages: newEnvelopes, has_more } = await api.chats.dm.fetchMessages(
this.dmData.userId,
this.currentUser.authToken,
limit,
oldestEnvelope.id
);
if (newEnvelopes && newEnvelopes.length > 0) {
const decryptedMessages: Message[] = [];
for (const env of newEnvelopes) {
try {
const dmMsg = await this.parseTextPayload(env, decryptedMessages);
decryptedMessages.push(dmMsg);
} catch (error) {
console.error("Error decrypting message:", error);
}
}
// Prepend older messages (they come in reverse chronological order)
this.updateState({
messages: [...decryptedMessages.reverse(), ...messages]
});
}
this.setHasMoreMessages(has_more);
} catch (error) {
console.error("Failed to load more DM messages:", error);
} finally {
this.setLoadingMore(false);
}
}
protected async sendMessage(content: string, replyToId?: number, files: File[] = []): Promise<void> {
if (!this.currentUser.authToken || !this.dmData || !content.trim()) return;
@@ -157,14 +195,14 @@ export class DMPanel extends MessagePanel {
const json = JSON.stringify(payload);
if (files.length === 0) {
await sendDMViaWebSocket(
await api.chats.dm.send(
this.dmData.userId,
this.dmData.publicKey,
json,
this.currentUser.authToken
);
} else {
await sendDmWithFiles(
await api.chats.dm.sendWithFiles(
this.dmData.userId,
this.dmData.publicKey,
json,
@@ -228,7 +266,7 @@ export class DMPanel extends MessagePanel {
const { id, iv, ciphertext, salt, iv2, wrappedMk } = response.data;
try {
// Decrypt new content in-place
const plaintext = await decryptDm(
const plaintext = await api.chats.dm.decrypt(
{
id,
senderId: 0,
@@ -330,7 +368,7 @@ export class DMPanel extends MessagePanel {
this.deleteMessageImmediately(messageId);
// Fire and forget server deletion; UI already updated
await deleteDmEnvelope(messageId, this.dmData.userId, this.currentUser.authToken);
await api.chats.dm.deleteMessage(messageId, this.dmData.userId, this.currentUser.authToken);
}
async handleEditMessage(messageId: number, content: string): Promise<void> {
@@ -345,7 +383,7 @@ export class DMPanel extends MessagePanel {
reply_to_id: msg?.reply_to?.id ?? undefined
}
};
editDmEnvelope(messageId, this.dmData.publicKey, JSON.stringify(payload), this.currentUser.authToken).catch((e) => {
api.chats.dm.edit(messageId, this.dmData.publicKey, JSON.stringify(payload), this.currentUser.authToken).catch((e) => {
console.error("Failed to edit DM:", e);
});
}
@@ -354,7 +392,7 @@ export class DMPanel extends MessagePanel {
if (!this.dmData || !this.currentUser.authToken) return null;
try {
const userProfile = await fetchUserProfileById(this.currentUser.authToken, this.dmData.userId);
const userProfile = await api.user.profile.fetchById(this.currentUser.authToken, this.dmData.userId);
if (!userProfile) return null;
return {
@@ -9,6 +9,8 @@ export interface MessagePanelState {
messages: Message[];
isLoading: boolean;
isTyping: boolean;
hasMoreMessages: boolean;
isLoadingMore: boolean;
}
export interface MessagePanelCallbacks {
@@ -35,7 +37,9 @@ export abstract class MessagePanel {
online: false,
messages: [],
isLoading: false,
isTyping: false
isTyping: false,
hasMoreMessages: false,
isLoadingMore: false
};
this.currentUser = currentUser;
}
@@ -107,6 +111,27 @@ export abstract class MessagePanel {
this.updateState({ isTyping: typing });
}
protected setLoadingMore(loading: boolean): void {
this.updateState({ isLoadingMore: loading });
}
protected setHasMoreMessages(hasMore: boolean): void {
this.updateState({ hasMoreMessages: hasMore });
}
/**
* Calculate message limit based on viewport height (5x screen height)
*/
protected calculateMessageLimit(): number {
const viewportHeight = window.innerHeight;
return Math.ceil((viewportHeight * 5) / 100);
}
/**
* Load more messages (to be implemented by subclasses)
*/
abstract loadMoreMessages(): Promise<void>;
// Getters
getState(): MessagePanelState {
return { ...this.state };
@@ -2,7 +2,7 @@ import { MessagePanel } from "./MessagePanel";
import { request } from "@/core/websocket";
import type { ChatWebSocketMessage, Message, ReactionUpdateWebSocketMessage } from "@/core/types";
import type { UserState, ProfileDialogData } from "@/state/types";
import { fetchMessages, sendMessage, sendMessageWithFiles } from "@/core/api/messaging";
import api from "@/core/api";
export class PublicChatPanel extends MessagePanel {
private messagesLoaded: boolean = false;
@@ -41,13 +41,15 @@ export class PublicChatPanel extends MessagePanel {
this.setLoading(true);
try {
const messages = await fetchMessages(this.currentUser.authToken);
const limit = this.calculateMessageLimit();
const { messages, has_more } = await api.chats.general.fetchMessages(this.currentUser.authToken, limit);
if (messages && messages.length > 0) {
this.clearMessages();
messages.forEach((msg: Message) => {
this.addMessage(msg);
});
}
this.setHasMoreMessages(has_more);
this.messagesLoaded = true;
} catch (error) {
console.error("Error loading public chat messages:", error);
@@ -56,14 +58,43 @@ export class PublicChatPanel extends MessagePanel {
}
}
async loadMoreMessages(): Promise<void> {
if (!this.currentUser.authToken || !this.state.hasMoreMessages || this.state.isLoadingMore) return;
const messages = this.getMessages();
if (messages.length === 0) return;
const oldestMessage = messages[0];
this.setLoadingMore(true);
try {
const limit = this.calculateMessageLimit();
const { messages: newMessages, has_more } = await api.chats.general.fetchMessages(
this.currentUser.authToken,
limit,
oldestMessage.id
);
if (newMessages && newMessages.length > 0) {
// Prepend older messages (they come in reverse chronological order)
this.updateState({
messages: [...newMessages.reverse(), ...messages]
});
}
this.setHasMoreMessages(has_more);
} catch (error) {
console.error("Error loading more public chat messages:", error);
} finally {
this.setLoadingMore(false);
}
}
protected async sendMessage(content: string, replyToId?: number, files: File[] = []): Promise<void> {
if (!this.currentUser.authToken || !content.trim()) return;
try {
if (files.length === 0) {
await sendMessage(content, replyToId ?? null, this.currentUser.authToken);
await api.chats.general.send(content, replyToId ?? null, this.currentUser.authToken);
} else {
await sendMessageWithFiles(content, replyToId ?? null, files, this.currentUser.authToken);
await api.chats.general.sendWithFiles(content, replyToId ?? null, files, this.currentUser.authToken);
}
} catch (error) {
console.error("Error sending message:", error);
+7 -25
View File
@@ -1,9 +1,7 @@
import { create } from "zustand";
import type { User } from "@/core/types";
import { request } from "@/core/websocket";
import { restoreKeys } from "@/core/api/account";
import api from "@/core/api";
import { API_BASE_URL } from "@/core/config";
import { getAuthHeaders } from "@/core/api/account";
import { initialize, subscribe, startElectronReceiver, isSupported } from "@/core/push-notifications/push-notifications";
import { isElectron } from "@/core/electron/electron";
import { onlineStatusManager } from "@/core/onlineStatusManager";
@@ -45,16 +43,8 @@ export const useUserStore = create<UserStore>((set) => ({
console.error('Failed to store credentials in localStorage:', error);
}
try {
request({
type: "ping",
credentials: {
scheme: "Bearer",
credentials: token
},
data: {}
})
} catch {}
// Ping will be sent automatically on WebSocket reconnect
// No need to send here to avoid duplicate pings
},
logout: () => {
try {
@@ -84,11 +74,11 @@ export const useUserStore = create<UserStore>((set) => ({
if (token) {
const fullResponse = await fetch(`${API_BASE_URL}/user/profile`, {
headers: getAuthHeaders(token, true)
headers: api.user.auth.getAuthHeaders(token, true)
});
if (fullResponse.ok) {
const user: User = await fullResponse.json();
restoreKeys();
api.user.auth.restoreKeys();
if (user.suspended) {
set({
@@ -114,16 +104,8 @@ export const useUserStore = create<UserStore>((set) => ({
onlineStatusManager.setAuthToken(token);
typingManager.setAuthToken(token);
try {
request({
type: "ping",
credentials: {
scheme: "Bearer",
credentials: token
},
data: {}
})
} catch {}
// Ping will be sent automatically on WebSocket reconnect
// No need to send here to avoid duplicate pings
try {
if (isSupported()) {
+1
View File
@@ -69,6 +69,7 @@
"electron-squirrel-startup": "^1.0.1",
"escape-string-regexp": "^5.0.0",
"he": "^1.2.0",
"idb": "^8.0.3",
"marked": "^16.3.0",
"mdui": "^2.1.4",
"motion": "^12.23.24",