mirror of
https://github.com/fromchat-messenger/web.git
synced 2026-09-22 11:05:05 +03:00
Merge branch 'refactor/structure'
This commit is contained in:
@@ -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
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
+373
-695
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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
|
||||
"a": "а",
|
||||
"A": "а",
|
||||
"c": "с",
|
||||
"C": "с",
|
||||
"e": "е",
|
||||
"E": "е",
|
||||
"f": "ф",
|
||||
"F": "ф",
|
||||
"g": "г",
|
||||
"G": "г",
|
||||
"i": "и",
|
||||
"I": "и",
|
||||
"m": "м",
|
||||
"M": "м",
|
||||
"n": "н",
|
||||
"N": "н",
|
||||
"o": "о",
|
||||
"O": "о",
|
||||
"p": "п",
|
||||
"P": "п",
|
||||
"s": "с",
|
||||
"S": "с",
|
||||
"t": "т",
|
||||
"T": "т",
|
||||
"u": "у",
|
||||
"U": "у",
|
||||
"v": "в",
|
||||
"V": "в",
|
||||
"x": "х",
|
||||
"X": "х",
|
||||
"y": "у",
|
||||
"Y": "у",
|
||||
"z": "з", # Full-width 'z' to Cyrillic 'з'
|
||||
"Z": "з",
|
||||
# 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
@@ -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)
|
||||
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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 };
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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();
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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 ? {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user