absolute annurism, pls never use surrealdb with solid.js if you want thinsg to work :) this si the very last time I'll ever touch solid.js and surrealdb, thanks..
This commit is contained in:
parent
7b061955e0
commit
de7d8ea8e6
|
|
@ -39,7 +39,7 @@ REDIS_URL=redis://redis:6379
|
||||||
|
|
||||||
# WebAuthn / Passkeys
|
# WebAuthn / Passkeys
|
||||||
RP_ID=localhost
|
RP_ID=localhost
|
||||||
RP_NAME=Encrypted P2P Chat
|
RP_NAME="Encrypted P2P Chat"
|
||||||
RP_ORIGIN=http://localhost
|
RP_ORIGIN=http://localhost
|
||||||
|
|
||||||
# Frontend (Vite requires VITE_ prefix)
|
# Frontend (Vite requires VITE_ prefix)
|
||||||
|
|
@ -48,7 +48,7 @@ VITE_WS_URL=ws://localhost:8000
|
||||||
VITE_RP_ID=localhost
|
VITE_RP_ID=localhost
|
||||||
|
|
||||||
# CORS
|
# CORS
|
||||||
CORS_ORIGINS=["http://localhost:3000","http://localhost:5173"]
|
CORS_ORIGINS='["http://localhost:3000","http://localhost:5173"]'
|
||||||
|
|
||||||
# WebSocket
|
# WebSocket
|
||||||
WS_HEARTBEAT_INTERVAL=30
|
WS_HEARTBEAT_INTERVAL=30
|
||||||
|
|
|
||||||
|
|
@ -1,110 +0,0 @@
|
||||||
# ⒸAngelaMos | 2025
|
|
||||||
# Makefile
|
|
||||||
|
|
||||||
.PHONY: help setup setup-backend setup-frontend env dev prod build-dev build-prod up-dev up-prod down-dev down-prod logs-dev logs-prod test-backend clean
|
|
||||||
|
|
||||||
help:
|
|
||||||
@echo "Encrypted P2P Chat - Makefile Commands"
|
|
||||||
@echo ""
|
|
||||||
@echo "Setup:"
|
|
||||||
@echo " make setup - Complete project setup (backend + frontend)"
|
|
||||||
@echo " make setup-backend - Setup backend (venv, install deps)"
|
|
||||||
@echo " make setup-frontend - Setup frontend (install npm deps)"
|
|
||||||
@echo " make env - Copy .env.example to .env files"
|
|
||||||
@echo ""
|
|
||||||
@echo "Development:"
|
|
||||||
@echo " make dev - Start development environment"
|
|
||||||
@echo " make build-dev - Build development Docker images"
|
|
||||||
@echo " make up-dev - Start development containers"
|
|
||||||
@echo " make down-dev - Stop development containers"
|
|
||||||
@echo " make logs-dev - Follow development logs"
|
|
||||||
@echo ""
|
|
||||||
@echo "Production:"
|
|
||||||
@echo " make prod - Start production environment"
|
|
||||||
@echo " make build-prod - Build production Docker images"
|
|
||||||
@echo " make up-prod - Start production containers"
|
|
||||||
@echo " make down-prod - Stop production containers"
|
|
||||||
@echo " make logs-prod - Follow production logs"
|
|
||||||
@echo ""
|
|
||||||
@echo "Testing:"
|
|
||||||
@echo " make test-backend - Run backend tests"
|
|
||||||
@echo ""
|
|
||||||
@echo "Cleanup:"
|
|
||||||
@echo " make clean - Remove all containers, volumes, and build artifacts"
|
|
||||||
|
|
||||||
setup: setup-backend setup-frontend env
|
|
||||||
@echo "Setup complete!"
|
|
||||||
|
|
||||||
setup-backend:
|
|
||||||
@echo "Setting up backend..."
|
|
||||||
cd backend && python3 -m venv ../.venv
|
|
||||||
. .venv/bin/activate && cd backend && pip install -e .[dev]
|
|
||||||
@echo "Backend setup complete!"
|
|
||||||
|
|
||||||
setup-frontend:
|
|
||||||
@echo "Setting up frontend..."
|
|
||||||
cd frontend && npm install
|
|
||||||
@echo "Frontend setup complete!"
|
|
||||||
|
|
||||||
env:
|
|
||||||
@echo "Creating .env files..."
|
|
||||||
@if [ ! -f .env ]; then cp .env.example .env; echo "Created root .env"; fi
|
|
||||||
@if [ ! -f frontend/.env ]; then cp frontend/.env.example frontend/.env; echo "Created frontend/.env"; fi
|
|
||||||
@echo ".env files created! Please update with your values."
|
|
||||||
|
|
||||||
dev: build-dev up-dev
|
|
||||||
|
|
||||||
build-dev:
|
|
||||||
@echo "Building development Docker images..."
|
|
||||||
docker compose -f docker-compose.dev.yml build
|
|
||||||
|
|
||||||
up-dev:
|
|
||||||
@echo "Starting development environment..."
|
|
||||||
docker compose -f docker-compose.dev.yml up -d
|
|
||||||
@echo "Development environment started!"
|
|
||||||
@echo "Frontend: http://localhost:5173"
|
|
||||||
@echo "Backend: http://localhost:8000"
|
|
||||||
@echo "Nginx: http://localhost"
|
|
||||||
|
|
||||||
down-dev:
|
|
||||||
@echo "Stopping development environment..."
|
|
||||||
docker compose -f docker-compose.dev.yml down
|
|
||||||
|
|
||||||
logs-dev:
|
|
||||||
docker compose -f docker-compose.dev.yml logs -f
|
|
||||||
|
|
||||||
prod: build-prod up-prod
|
|
||||||
|
|
||||||
build-prod:
|
|
||||||
@echo "Building production Docker images..."
|
|
||||||
docker compose -f docker-compose.prod.yml build
|
|
||||||
|
|
||||||
up-prod:
|
|
||||||
@echo "Starting production environment..."
|
|
||||||
docker compose -f docker-compose.prod.yml up -d
|
|
||||||
@echo "Production environment started!"
|
|
||||||
@echo "Application: http://localhost"
|
|
||||||
|
|
||||||
down-prod:
|
|
||||||
@echo "Stopping production environment..."
|
|
||||||
docker compose -f docker-compose.prod.yml down
|
|
||||||
|
|
||||||
logs-prod:
|
|
||||||
docker compose -f docker-compose.prod.yml logs -f
|
|
||||||
|
|
||||||
test-backend:
|
|
||||||
@echo "Running backend tests..."
|
|
||||||
. .venv/bin/activate && cd backend && python -m pytest tests/ -v
|
|
||||||
|
|
||||||
clean:
|
|
||||||
@echo "Cleaning up..."
|
|
||||||
docker compose -f docker-compose.dev.yml down -v
|
|
||||||
docker compose -f docker-compose.prod.yml down -v
|
|
||||||
rm -rf frontend/node_modules
|
|
||||||
rm -rf frontend/dist
|
|
||||||
rm -rf backend/.venv
|
|
||||||
rm -rf backend/__pycache__
|
|
||||||
rm -rf backend/.pytest_cache
|
|
||||||
rm -rf backend/.mypy_cache
|
|
||||||
rm -rf backend/.ruff_cache
|
|
||||||
@echo "Cleanup complete!"
|
|
||||||
|
|
@ -31,6 +31,7 @@ End-to-end encrypted P2P chat application with Signal Protocol (Double Ratchet +
|
||||||
- Docker and Docker Compose
|
- Docker and Docker Compose
|
||||||
- **Node.js 20.19+ or 22.12+** (required for Vite 7)
|
- **Node.js 20.19+ or 22.12+** (required for Vite 7)
|
||||||
- **Python 3.13+** (latest stable)
|
- **Python 3.13+** (latest stable)
|
||||||
|
- **uv** (Python package manager) - `curl -LsSf https://astral.sh/uv/install.sh | sh`
|
||||||
- Make
|
- Make
|
||||||
|
|
||||||
### Setup
|
### Setup
|
||||||
|
|
@ -161,10 +162,9 @@ encrypted-p2p-chat/
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd backend
|
cd backend
|
||||||
python -m venv ../.venv
|
uv venv ../.venv
|
||||||
source ../.venv/bin/activate
|
uv pip install -e .[dev]
|
||||||
pip install -e .[dev]
|
uv run pytest tests/ -v
|
||||||
python -m pytest tests/ -v
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Frontend Development
|
### Frontend Development
|
||||||
|
|
@ -189,7 +189,7 @@ Or manually:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd backend
|
cd backend
|
||||||
python -m pytest tests/ -v
|
uv run pytest tests/ -v
|
||||||
```
|
```
|
||||||
|
|
||||||
## Environment Variables
|
## Environment Variables
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,11 @@ Encryption endpoints for X3DH prekey bundles
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
from typing import Optional
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, status
|
from fastapi import APIRouter, Depends, status
|
||||||
|
from pydantic import BaseModel
|
||||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
|
|
||||||
from app.models.Base import get_session
|
from app.models.Base import get_session
|
||||||
|
|
@ -19,6 +21,14 @@ logger = logging.getLogger(__name__)
|
||||||
router = APIRouter(prefix = "/encryption", tags = ["encryption"])
|
router = APIRouter(prefix = "/encryption", tags = ["encryption"])
|
||||||
|
|
||||||
|
|
||||||
|
class ClientKeysUpload(BaseModel):
|
||||||
|
identity_key: str
|
||||||
|
identity_key_ed25519: str
|
||||||
|
signed_prekey: str
|
||||||
|
signed_prekey_signature: str
|
||||||
|
one_time_prekeys: list[str]
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/prekey-bundle/{user_id}",
|
"/prekey-bundle/{user_id}",
|
||||||
status_code = status.HTTP_200_OK,
|
status_code = status.HTTP_200_OK,
|
||||||
|
|
@ -48,6 +58,7 @@ async def initialize_keys(
|
||||||
) -> dict[str,
|
) -> dict[str,
|
||||||
str]:
|
str]:
|
||||||
"""
|
"""
|
||||||
|
[DEPRECATED] Server-side key generation - kept for backwards compatibility
|
||||||
Initializes encryption keys for a user
|
Initializes encryption keys for a user
|
||||||
"""
|
"""
|
||||||
await prekey_service.initialize_user_keys(session, user_id)
|
await prekey_service.initialize_user_keys(session, user_id)
|
||||||
|
|
@ -58,6 +69,32 @@ async def initialize_keys(
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/upload-keys/{user_id}", status_code = status.HTTP_201_CREATED)
|
||||||
|
async def upload_client_keys(
|
||||||
|
user_id: UUID,
|
||||||
|
keys: ClientKeysUpload,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> dict[str,
|
||||||
|
str]:
|
||||||
|
"""
|
||||||
|
Stores client-generated public keys for E2E encryption
|
||||||
|
"""
|
||||||
|
await prekey_service.store_client_keys(
|
||||||
|
session,
|
||||||
|
user_id,
|
||||||
|
keys.identity_key,
|
||||||
|
keys.identity_key_ed25519,
|
||||||
|
keys.signed_prekey,
|
||||||
|
keys.signed_prekey_signature,
|
||||||
|
keys.one_time_prekeys
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"message": f"Stored client keys for user {user_id}"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/rotate-signed-prekey/{user_id}", status_code = status.HTTP_200_OK)
|
@router.post("/rotate-signed-prekey/{user_id}", status_code = status.HTTP_200_OK)
|
||||||
async def rotate_signed_prekey(
|
async def rotate_signed_prekey(
|
||||||
user_id: UUID,
|
user_id: UUID,
|
||||||
|
|
|
||||||
|
|
@ -140,8 +140,6 @@ async def list_rooms(
|
||||||
"""
|
"""
|
||||||
List all rooms for the specified user
|
List all rooms for the specified user
|
||||||
"""
|
"""
|
||||||
logger.info("list_rooms called for user_id: %s", user_id)
|
|
||||||
|
|
||||||
user = await auth_service.get_user_by_id(session, UUID(user_id))
|
user = await auth_service.get_user_by_id(session, UUID(user_id))
|
||||||
|
|
||||||
if not user:
|
if not user:
|
||||||
|
|
@ -151,8 +149,6 @@ async def list_rooms(
|
||||||
)
|
)
|
||||||
|
|
||||||
room_data_list = await surreal_db.get_rooms_for_user(user_id)
|
room_data_list = await surreal_db.get_rooms_for_user(user_id)
|
||||||
logger.info("SurrealDB returned %d rooms for user %s", len(room_data_list), user_id)
|
|
||||||
logger.info("Room data: %s", room_data_list)
|
|
||||||
|
|
||||||
rooms: list[RoomAPIResponse] = []
|
rooms: list[RoomAPIResponse] = []
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ class PreKeyBundle:
|
||||||
Recipient prekey bundle for X3DH protocol
|
Recipient prekey bundle for X3DH protocol
|
||||||
"""
|
"""
|
||||||
identity_key: str
|
identity_key: str
|
||||||
|
identity_key_ed25519: str
|
||||||
signed_prekey: str
|
signed_prekey: str
|
||||||
signed_prekey_signature: str
|
signed_prekey_signature: str
|
||||||
one_time_prekey: str | None = None
|
one_time_prekey: str | None = None
|
||||||
|
|
|
||||||
|
|
@ -44,13 +44,6 @@ class SurrealDBManager:
|
||||||
if self._connected:
|
if self._connected:
|
||||||
return
|
return
|
||||||
|
|
||||||
logger.warning(
|
|
||||||
"SurrealDB connecting: URL=%s, NS=%s, DB=%s",
|
|
||||||
settings.SURREAL_URL,
|
|
||||||
settings.SURREAL_NAMESPACE,
|
|
||||||
settings.SURREAL_DATABASE,
|
|
||||||
)
|
|
||||||
|
|
||||||
self.db = AsyncSurreal(settings.SURREAL_URL)
|
self.db = AsyncSurreal(settings.SURREAL_URL)
|
||||||
await self.db.connect()
|
await self.db.connect()
|
||||||
|
|
||||||
|
|
@ -67,11 +60,6 @@ class SurrealDBManager:
|
||||||
)
|
)
|
||||||
|
|
||||||
self._connected = True
|
self._connected = True
|
||||||
logger.warning(
|
|
||||||
"SurrealDB connected successfully to %s/%s",
|
|
||||||
settings.SURREAL_NAMESPACE,
|
|
||||||
settings.SURREAL_DATABASE,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def disconnect(self) -> None:
|
async def disconnect(self) -> None:
|
||||||
"""
|
"""
|
||||||
|
|
@ -87,31 +75,8 @@ class SurrealDBManager:
|
||||||
Ensure connection is established
|
Ensure connection is established
|
||||||
"""
|
"""
|
||||||
if not self._connected:
|
if not self._connected:
|
||||||
logger.warning("ensure_connected: Connection was lost, reconnecting...")
|
|
||||||
await self.connect()
|
await self.connect()
|
||||||
else:
|
|
||||||
logger.warning(
|
|
||||||
"ensure_connected: Already connected (id=%s)",
|
|
||||||
id(self.db)
|
|
||||||
)
|
|
||||||
|
|
||||||
async def _debug_connection_state(self) -> dict[str, Any]:
|
|
||||||
"""
|
|
||||||
Debug helper to check current connection state
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
info_result = await self.db.query("INFO FOR DB")
|
|
||||||
rooms_count = await self.db.query("SELECT count() FROM rooms GROUP ALL")
|
|
||||||
participants_count = await self.db.query(
|
|
||||||
"SELECT count() FROM room_participants GROUP ALL"
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
"db_info": info_result,
|
|
||||||
"rooms_count": rooms_count,
|
|
||||||
"participants_count": participants_count,
|
|
||||||
}
|
|
||||||
except Exception as e:
|
|
||||||
return {"error": str(e)}
|
|
||||||
|
|
||||||
def _extract_query_result(self, result: Any) -> list[dict[str, Any]]:
|
def _extract_query_result(self, result: Any) -> list[dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
|
|
@ -146,16 +111,13 @@ class SurrealDBManager:
|
||||||
|
|
||||||
async def create_message(
|
async def create_message(
|
||||||
self,
|
self,
|
||||||
message_data: dict[str,
|
message_data: dict[str, Any]
|
||||||
Any]
|
|
||||||
) -> MessageResponse:
|
) -> MessageResponse:
|
||||||
"""
|
"""
|
||||||
Create a new message in SurrealDB
|
Create a new message in SurrealDB
|
||||||
"""
|
"""
|
||||||
await self.ensure_connected()
|
await self.ensure_connected()
|
||||||
logger.warning("create_message - data: %s", message_data)
|
|
||||||
result = await self.db.create("messages", message_data)
|
result = await self.db.create("messages", message_data)
|
||||||
logger.warning("create_message - result: %s", result)
|
|
||||||
result["id"] = str(result["id"])
|
result["id"] = str(result["id"])
|
||||||
return MessageResponse(**result)
|
return MessageResponse(**result)
|
||||||
|
|
||||||
|
|
@ -169,10 +131,6 @@ class SurrealDBManager:
|
||||||
Get messages for a specific room with pagination
|
Get messages for a specific room with pagination
|
||||||
"""
|
"""
|
||||||
await self.ensure_connected()
|
await self.ensure_connected()
|
||||||
logger.warning("get_room_messages - room_id: %s", room_id)
|
|
||||||
|
|
||||||
all_messages = await self.db.query("SELECT * FROM messages")
|
|
||||||
logger.warning("get_room_messages - ALL messages in DB: %s", all_messages)
|
|
||||||
|
|
||||||
query = """
|
query = """
|
||||||
SELECT * FROM messages
|
SELECT * FROM messages
|
||||||
|
|
@ -189,9 +147,9 @@ class SurrealDBManager:
|
||||||
"offset": offset,
|
"offset": offset,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
logger.warning("get_room_messages - RAW result: %s", result)
|
|
||||||
messages = self._extract_query_result(result)
|
messages = self._extract_query_result(result)
|
||||||
logger.warning("get_room_messages - extracted messages: %s", messages)
|
for msg in messages:
|
||||||
|
msg["id"] = str(msg["id"])
|
||||||
return [MessageResponse(**msg) for msg in messages]
|
return [MessageResponse(**msg) for msg in messages]
|
||||||
|
|
||||||
async def create_room(self, room_data: dict[str, Any]) -> RoomResponse:
|
async def create_room(self, room_data: dict[str, Any]) -> RoomResponse:
|
||||||
|
|
@ -200,15 +158,11 @@ class SurrealDBManager:
|
||||||
"""
|
"""
|
||||||
await self.ensure_connected()
|
await self.ensure_connected()
|
||||||
query = "CREATE rooms CONTENT $data"
|
query = "CREATE rooms CONTENT $data"
|
||||||
logger.warning("create_room - executing query with data: %s", room_data)
|
|
||||||
|
|
||||||
result = await self.db.query(query, {"data": room_data})
|
result = await self.db.query(query, {"data": room_data})
|
||||||
logger.warning("create_room - RAW result type: %s", type(result))
|
|
||||||
logger.warning("create_room - RAW result: %s", result)
|
|
||||||
|
|
||||||
if isinstance(result, list) and len(result) > 0:
|
if isinstance(result, list) and len(result) > 0:
|
||||||
first = result[0]
|
first = result[0]
|
||||||
logger.info("create_room - first element type: %s, value: %s", type(first), first)
|
|
||||||
|
|
||||||
if isinstance(first, dict):
|
if isinstance(first, dict):
|
||||||
if "result" in first and first["result"]:
|
if "result" in first and first["result"]:
|
||||||
|
|
@ -216,19 +170,15 @@ class SurrealDBManager:
|
||||||
elif "id" in first:
|
elif "id" in first:
|
||||||
room = first
|
room = first
|
||||||
else:
|
else:
|
||||||
logger.error("create_room - unexpected dict structure: %s", first)
|
|
||||||
raise ValueError(f"Unexpected result structure: {first}")
|
raise ValueError(f"Unexpected result structure: {first}")
|
||||||
elif isinstance(first, list) and len(first) > 0:
|
elif isinstance(first, list) and len(first) > 0:
|
||||||
room = first[0]
|
room = first[0]
|
||||||
else:
|
else:
|
||||||
logger.error("create_room - unexpected first element: %s", first)
|
|
||||||
raise ValueError(f"Unexpected result: {first}")
|
raise ValueError(f"Unexpected result: {first}")
|
||||||
|
|
||||||
room["id"] = str(room["id"])
|
room["id"] = str(room["id"])
|
||||||
logger.info("create_room - final room: %s", room)
|
|
||||||
return RoomResponse(**room)
|
return RoomResponse(**room)
|
||||||
|
|
||||||
logger.error("create_room - no results returned: %s", result)
|
|
||||||
raise ValueError(f"Failed to create room, result: {result}")
|
raise ValueError(f"Failed to create room, result: {result}")
|
||||||
|
|
||||||
async def add_room_participant(
|
async def add_room_participant(
|
||||||
|
|
@ -238,10 +188,21 @@ class SurrealDBManager:
|
||||||
role: str = "member",
|
role: str = "member",
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Add a participant to a room
|
Add a participant to a room (skips if already exists)
|
||||||
"""
|
"""
|
||||||
await self.ensure_connected()
|
await self.ensure_connected()
|
||||||
|
|
||||||
|
check_query = """
|
||||||
|
SELECT * FROM room_participants
|
||||||
|
WHERE room_id = $room_id AND user_id = $user_id
|
||||||
|
"""
|
||||||
|
existing = await self.db.query(check_query, {"room_id": room_id, "user_id": user_id})
|
||||||
|
existing_data = self._extract_query_result(existing)
|
||||||
|
|
||||||
|
if existing_data:
|
||||||
|
logger.info("Participant %s already in room %s, skipping", user_id, room_id)
|
||||||
|
return
|
||||||
|
|
||||||
query = """
|
query = """
|
||||||
CREATE room_participants CONTENT {
|
CREATE room_participants CONTENT {
|
||||||
room_id: $room_id,
|
room_id: $room_id,
|
||||||
|
|
@ -256,16 +217,8 @@ class SurrealDBManager:
|
||||||
"role": role,
|
"role": role,
|
||||||
"joined_at": datetime.now(UTC).isoformat(),
|
"joined_at": datetime.now(UTC).isoformat(),
|
||||||
}
|
}
|
||||||
logger.warning("add_room_participant - params: %s", params)
|
|
||||||
|
|
||||||
result = await self.db.query(query, params)
|
await self.db.query(query, params)
|
||||||
logger.warning("add_room_participant result: %s", result)
|
|
||||||
|
|
||||||
verify = await self.db.query("SELECT * FROM room_participants")
|
|
||||||
logger.warning("VERIFY ALL room_participants: %s", verify)
|
|
||||||
|
|
||||||
debug_state = await self._debug_connection_state()
|
|
||||||
logger.warning("add_room_participant - DB STATE after insert: %s", debug_state)
|
|
||||||
|
|
||||||
async def get_rooms_for_user(self, user_id: str) -> list[dict[str, Any]]:
|
async def get_rooms_for_user(self, user_id: str) -> list[dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
|
|
@ -273,27 +226,18 @@ class SurrealDBManager:
|
||||||
"""
|
"""
|
||||||
await self.ensure_connected()
|
await self.ensure_connected()
|
||||||
|
|
||||||
debug_state = await self._debug_connection_state()
|
|
||||||
logger.warning("get_rooms_for_user - DB STATE: %s", debug_state)
|
|
||||||
|
|
||||||
participants_query = """
|
participants_query = """
|
||||||
SELECT room_id FROM room_participants WHERE user_id = $user_id
|
SELECT room_id FROM room_participants WHERE user_id = $user_id
|
||||||
"""
|
"""
|
||||||
logger.warning("get_rooms_for_user - fetching participants for user_id: %s", user_id)
|
|
||||||
result = await self.db.query(participants_query, {"user_id": user_id})
|
result = await self.db.query(participants_query, {"user_id": user_id})
|
||||||
logger.warning("get_rooms_for_user - RAW result: %s", result)
|
|
||||||
participant_data = self._extract_query_result(result)
|
participant_data = self._extract_query_result(result)
|
||||||
logger.warning("Participant data after extract: %s", participant_data)
|
|
||||||
|
|
||||||
if not participant_data:
|
if not participant_data:
|
||||||
logger.warning("No room_participants found for user %s", user_id)
|
|
||||||
return []
|
return []
|
||||||
|
|
||||||
room_ids = [p["room_id"] for p in participant_data if p.get("room_id")]
|
room_ids = list({p["room_id"] for p in participant_data if p.get("room_id")})
|
||||||
logger.info("Room IDs to fetch: %s", room_ids)
|
|
||||||
|
|
||||||
if not room_ids:
|
if not room_ids:
|
||||||
logger.warning("No valid room_ids for user %s", user_id)
|
|
||||||
return []
|
return []
|
||||||
|
|
||||||
rooms: list[dict[str, Any]] = []
|
rooms: list[dict[str, Any]] = []
|
||||||
|
|
@ -305,10 +249,9 @@ class SurrealDBManager:
|
||||||
rooms.extend(room)
|
rooms.extend(room)
|
||||||
else:
|
else:
|
||||||
rooms.append(room)
|
rooms.append(room)
|
||||||
except Exception as e:
|
except Exception:
|
||||||
logger.warning("Failed to fetch room %s: %s", room_id, e)
|
pass
|
||||||
|
|
||||||
logger.info("Fetched %d rooms", len(rooms))
|
|
||||||
return rooms
|
return rooms
|
||||||
|
|
||||||
async def get_room_participants(self, room_id: str) -> list[dict[str, Any]]:
|
async def get_room_participants(self, room_id: str) -> list[dict[str, Any]]:
|
||||||
|
|
@ -335,7 +278,10 @@ class SurrealDBManager:
|
||||||
result = await self.db.query(query, {"user_id": f"users:`{user_id}`"})
|
result = await self.db.query(query, {"user_id": f"users:`{user_id}`"})
|
||||||
data = self._extract_query_result(result)
|
data = self._extract_query_result(result)
|
||||||
if data and isinstance(data[0], dict) and data[0].get("rooms"):
|
if data and isinstance(data[0], dict) and data[0].get("rooms"):
|
||||||
return [RoomResponse(**room) for room in data[0]["rooms"]]
|
rooms = data[0]["rooms"]
|
||||||
|
for room in rooms:
|
||||||
|
room["id"] = str(room["id"])
|
||||||
|
return [RoomResponse(**room) for room in rooms]
|
||||||
return []
|
return []
|
||||||
|
|
||||||
async def update_presence(
|
async def update_presence(
|
||||||
|
|
|
||||||
|
|
@ -68,6 +68,7 @@ def create_app() -> FastAPI:
|
||||||
title = settings.APP_NAME,
|
title = settings.APP_NAME,
|
||||||
description = APP_DESCRIPTION,
|
description = APP_DESCRIPTION,
|
||||||
version = APP_VERSION,
|
version = APP_VERSION,
|
||||||
|
openapi_version = "3.1.0",
|
||||||
docs_url = "/docs" if settings.is_development else None,
|
docs_url = "/docs" if settings.is_development else None,
|
||||||
redoc_url = "/redoc" if settings.is_development else None,
|
redoc_url = "/redoc" if settings.is_development else None,
|
||||||
default_response_class = ORJSONResponse,
|
default_response_class = ORJSONResponse,
|
||||||
|
|
|
||||||
|
|
@ -266,6 +266,53 @@ class MessageService:
|
||||||
logger.error("Database error saving ratchet state: %s", e)
|
logger.error("Database error saving ratchet state: %s", e)
|
||||||
raise DatabaseError("Failed to save ratchet state") from e
|
raise DatabaseError("Failed to save ratchet state") from e
|
||||||
|
|
||||||
|
async def store_encrypted_message(
|
||||||
|
self,
|
||||||
|
session: AsyncSession,
|
||||||
|
sender_id: UUID,
|
||||||
|
recipient_id: UUID,
|
||||||
|
ciphertext: str,
|
||||||
|
nonce: str,
|
||||||
|
header: str,
|
||||||
|
room_id: str | None = None,
|
||||||
|
) -> Any:
|
||||||
|
"""
|
||||||
|
Stores client-encrypted message in SurrealDB (pass-through, no server encryption)
|
||||||
|
"""
|
||||||
|
sender_user_statement = select(User).where(User.id == sender_id)
|
||||||
|
sender_user_result = await session.execute(sender_user_statement)
|
||||||
|
sender_user = sender_user_result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not sender_user:
|
||||||
|
raise UserNotFoundError("Sender not found")
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
surreal_message = {
|
||||||
|
"sender_id": str(sender_id),
|
||||||
|
"recipient_id": str(recipient_id),
|
||||||
|
"room_id": room_id,
|
||||||
|
"ciphertext": ciphertext,
|
||||||
|
"nonce": nonce,
|
||||||
|
"header": header,
|
||||||
|
"sender_username": sender_user.username,
|
||||||
|
"created_at": now.isoformat(),
|
||||||
|
"updated_at": now.isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await surreal_db.create_message(surreal_message)
|
||||||
|
logger.info(
|
||||||
|
"Stored client-encrypted message: %s -> %s",
|
||||||
|
sender_id,
|
||||||
|
recipient_id
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to store encrypted message: %s", e)
|
||||||
|
raise DatabaseError(f"Failed to store message: {str(e)}") from e
|
||||||
|
|
||||||
async def send_encrypted_message(
|
async def send_encrypted_message(
|
||||||
self,
|
self,
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
|
|
@ -275,6 +322,7 @@ class MessageService:
|
||||||
room_id: str | None = None,
|
room_id: str | None = None,
|
||||||
) -> Any:
|
) -> Any:
|
||||||
"""
|
"""
|
||||||
|
[DEPRECATED] Server-side encryption - kept for backwards compatibility
|
||||||
Encrypts message with Double Ratchet and stores in SurrealDB
|
Encrypts message with Double Ratchet and stores in SurrealDB
|
||||||
"""
|
"""
|
||||||
ratchet_state_statement = select(RatchetState).where(
|
ratchet_state_statement = select(RatchetState).where(
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,113 @@ class PrekeyService:
|
||||||
"""
|
"""
|
||||||
Service for managing X3DH prekey bundles and key rotation
|
Service for managing X3DH prekey bundles and key rotation
|
||||||
"""
|
"""
|
||||||
|
async def store_client_keys(
|
||||||
|
self,
|
||||||
|
session: AsyncSession,
|
||||||
|
user_id: UUID,
|
||||||
|
identity_key: str,
|
||||||
|
identity_key_ed25519: str,
|
||||||
|
signed_prekey: str,
|
||||||
|
signed_prekey_signature: str,
|
||||||
|
one_time_prekeys: list[str]
|
||||||
|
) -> IdentityKey:
|
||||||
|
"""
|
||||||
|
Stores client-generated public keys for E2E encryption.
|
||||||
|
Only stores PUBLIC keys - private keys remain on client.
|
||||||
|
"""
|
||||||
|
statement = select(User).where(User.id == user_id)
|
||||||
|
result = await session.execute(statement)
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
logger.error("User not found: %s", user_id)
|
||||||
|
raise UserNotFoundError("User not found")
|
||||||
|
|
||||||
|
existing_ik_statement = select(IdentityKey).where(
|
||||||
|
IdentityKey.user_id == user_id
|
||||||
|
)
|
||||||
|
existing_ik_result = await session.execute(existing_ik_statement)
|
||||||
|
existing_ik = existing_ik_result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if existing_ik:
|
||||||
|
existing_ik.public_key = identity_key
|
||||||
|
existing_ik.public_key_ed25519 = identity_key_ed25519
|
||||||
|
logger.info("Updated existing identity key for user %s", user_id)
|
||||||
|
else:
|
||||||
|
existing_ik = IdentityKey(
|
||||||
|
user_id = user_id,
|
||||||
|
public_key = identity_key,
|
||||||
|
private_key = "",
|
||||||
|
public_key_ed25519 = identity_key_ed25519,
|
||||||
|
private_key_ed25519 = ""
|
||||||
|
)
|
||||||
|
session.add(existing_ik)
|
||||||
|
logger.info("Created identity key for user %s", user_id)
|
||||||
|
|
||||||
|
old_spks_statement = select(SignedPrekey).where(
|
||||||
|
SignedPrekey.user_id == user_id,
|
||||||
|
SignedPrekey.is_active
|
||||||
|
)
|
||||||
|
old_spks_result = await session.execute(old_spks_statement)
|
||||||
|
old_spks = old_spks_result.scalars().all()
|
||||||
|
|
||||||
|
for old_spk in old_spks:
|
||||||
|
old_spk.is_active = False
|
||||||
|
|
||||||
|
max_key_id_statement = select(SignedPrekey.key_id).where(
|
||||||
|
SignedPrekey.user_id == user_id
|
||||||
|
).order_by(SignedPrekey.key_id.desc()).limit(1)
|
||||||
|
max_key_id_result = await session.execute(max_key_id_statement)
|
||||||
|
max_key_id = max_key_id_result.scalar_one_or_none()
|
||||||
|
new_spk_key_id = (max_key_id + 1) if max_key_id is not None else 1
|
||||||
|
|
||||||
|
expires_at = datetime.now(UTC) + timedelta(
|
||||||
|
hours = SIGNED_PREKEY_ROTATION_HOURS
|
||||||
|
)
|
||||||
|
|
||||||
|
new_spk = SignedPrekey(
|
||||||
|
user_id = user_id,
|
||||||
|
key_id = new_spk_key_id,
|
||||||
|
public_key = signed_prekey,
|
||||||
|
private_key = "",
|
||||||
|
signature = signed_prekey_signature,
|
||||||
|
is_active = True,
|
||||||
|
expires_at = expires_at
|
||||||
|
)
|
||||||
|
session.add(new_spk)
|
||||||
|
|
||||||
|
max_opk_key_id_statement = select(OneTimePrekey.key_id).where(
|
||||||
|
OneTimePrekey.user_id == user_id
|
||||||
|
).order_by(OneTimePrekey.key_id.desc()).limit(1)
|
||||||
|
max_opk_key_id_result = await session.execute(max_opk_key_id_statement)
|
||||||
|
max_opk_key_id = max_opk_key_id_result.scalar_one_or_none()
|
||||||
|
next_opk_key_id = (max_opk_key_id + 1) if max_opk_key_id is not None else 1
|
||||||
|
|
||||||
|
for i, opk_public in enumerate(one_time_prekeys):
|
||||||
|
new_opk = OneTimePrekey(
|
||||||
|
user_id = user_id,
|
||||||
|
key_id = next_opk_key_id + i,
|
||||||
|
public_key = opk_public,
|
||||||
|
private_key = "",
|
||||||
|
is_used = False
|
||||||
|
)
|
||||||
|
session.add(new_opk)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(existing_ik)
|
||||||
|
logger.info(
|
||||||
|
"Stored client keys for user %s: IK + SPK + %s OPKs",
|
||||||
|
user_id,
|
||||||
|
len(one_time_prekeys)
|
||||||
|
)
|
||||||
|
except IntegrityError as e:
|
||||||
|
await session.rollback()
|
||||||
|
logger.error("Database error storing client keys: %s", e)
|
||||||
|
raise DatabaseError("Failed to store client keys") from e
|
||||||
|
|
||||||
|
return existing_ik
|
||||||
|
|
||||||
async def initialize_user_keys(
|
async def initialize_user_keys(
|
||||||
self,
|
self,
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
|
|
@ -239,6 +346,7 @@ class PrekeyService:
|
||||||
|
|
||||||
bundle = PreKeyBundle(
|
bundle = PreKeyBundle(
|
||||||
identity_key = identity_key.public_key,
|
identity_key = identity_key.public_key,
|
||||||
|
identity_key_ed25519 = identity_key.public_key_ed25519,
|
||||||
signed_prekey = signed_prekey.public_key,
|
signed_prekey = signed_prekey.public_key,
|
||||||
signed_prekey_signature = signed_prekey.signature,
|
signed_prekey_signature = signed_prekey.signature,
|
||||||
one_time_prekey = one_time_prekey_public
|
one_time_prekey = one_time_prekey_public
|
||||||
|
|
|
||||||
|
|
@ -91,16 +91,18 @@ class WebSocketService:
|
||||||
Any]
|
Any]
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Process encrypted message from client and forward to recipient
|
Process client-encrypted message and forward to recipient (pass-through)
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
recipient_id = UUID(message.get("recipient_id"))
|
recipient_id = UUID(message.get("recipient_id"))
|
||||||
room_id = message.get("room_id")
|
room_id = message.get("room_id")
|
||||||
plaintext = message.get("plaintext")
|
ciphertext = message.get("ciphertext")
|
||||||
|
nonce = message.get("nonce")
|
||||||
|
header = message.get("header")
|
||||||
temp_id = message.get("temp_id", "")
|
temp_id = message.get("temp_id", "")
|
||||||
|
|
||||||
if not plaintext:
|
if not ciphertext or not nonce or not header:
|
||||||
logger.error("Missing plaintext in message from %s", user_id)
|
logger.error("Missing encryption fields in message from %s", user_id)
|
||||||
return
|
return
|
||||||
|
|
||||||
if not room_id:
|
if not room_id:
|
||||||
|
|
@ -108,11 +110,13 @@ class WebSocketService:
|
||||||
return
|
return
|
||||||
|
|
||||||
async with async_session_maker() as session:
|
async with async_session_maker() as session:
|
||||||
result = await message_service.send_encrypted_message(
|
result = await message_service.store_encrypted_message(
|
||||||
session,
|
session,
|
||||||
user_id,
|
user_id,
|
||||||
recipient_id,
|
recipient_id,
|
||||||
plaintext,
|
ciphertext,
|
||||||
|
nonce,
|
||||||
|
header,
|
||||||
room_id,
|
room_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -121,15 +125,15 @@ class WebSocketService:
|
||||||
sender_id = str(user_id),
|
sender_id = str(user_id),
|
||||||
recipient_id = str(recipient_id),
|
recipient_id = str(recipient_id),
|
||||||
room_id = room_id,
|
room_id = room_id,
|
||||||
content = plaintext,
|
content = "",
|
||||||
ciphertext = result.ciphertext if hasattr(result, 'ciphertext') else "",
|
ciphertext = ciphertext,
|
||||||
nonce = result.nonce if hasattr(result, 'nonce') else "",
|
nonce = nonce,
|
||||||
header = result.header if hasattr(result, 'header') else "",
|
header = header,
|
||||||
sender_username = result.sender_username if hasattr(result, 'sender_username') else ""
|
sender_username = result.sender_username if hasattr(result, 'sender_username') else ""
|
||||||
)
|
)
|
||||||
|
|
||||||
is_recipient_connected = connection_manager.is_user_connected(recipient_id)
|
is_recipient_connected = connection_manager.is_user_connected(recipient_id)
|
||||||
logger.warning(
|
logger.debug(
|
||||||
"Sending to recipient %s - connected: %s",
|
"Sending to recipient %s - connected: %s",
|
||||||
recipient_id,
|
recipient_id,
|
||||||
is_recipient_connected
|
is_recipient_connected
|
||||||
|
|
@ -139,7 +143,7 @@ class WebSocketService:
|
||||||
recipient_id,
|
recipient_id,
|
||||||
ws_message.model_dump(mode = "json")
|
ws_message.model_dump(mode = "json")
|
||||||
)
|
)
|
||||||
logger.warning("Message sent to recipient %s", recipient_id)
|
logger.debug("Message sent to recipient %s", recipient_id)
|
||||||
|
|
||||||
confirmation = MessageSentWS(
|
confirmation = MessageSentWS(
|
||||||
temp_id = temp_id,
|
temp_id = temp_id,
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -5,12 +5,12 @@
|
||||||
FROM python:3.13-slim
|
FROM python:3.13-slim
|
||||||
|
|
||||||
ENV PYTHONUNBUFFERED=1 \
|
ENV PYTHONUNBUFFERED=1 \
|
||||||
PYTHONDONTWRITEBYTECODE=1 \
|
PYTHONDONTWRITEBYTECODE=1
|
||||||
PIP_NO_CACHE_DIR=1 \
|
|
||||||
PIP_DISABLE_PIP_VERSION_CHECK=1
|
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
|
||||||
|
|
||||||
RUN apt-get update && \
|
RUN apt-get update && \
|
||||||
apt-get install -y --no-install-recommends \
|
apt-get install -y --no-install-recommends \
|
||||||
build-essential \
|
build-essential \
|
||||||
|
|
@ -19,7 +19,7 @@ RUN apt-get update && \
|
||||||
rm -rf /var/lib/apt/lists/*
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
COPY backend/pyproject.toml ./
|
COPY backend/pyproject.toml ./
|
||||||
RUN pip install -e .[dev]
|
RUN uv pip install --system -e .[dev]
|
||||||
|
|
||||||
COPY backend/ .
|
COPY backend/ .
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,12 +5,12 @@
|
||||||
FROM python:3.13-slim AS builder
|
FROM python:3.13-slim AS builder
|
||||||
|
|
||||||
ENV PYTHONUNBUFFERED=1 \
|
ENV PYTHONUNBUFFERED=1 \
|
||||||
PYTHONDONTWRITEBYTECODE=1 \
|
PYTHONDONTWRITEBYTECODE=1
|
||||||
PIP_NO_CACHE_DIR=1 \
|
|
||||||
PIP_DISABLE_PIP_VERSION_CHECK=1
|
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
|
||||||
|
|
||||||
RUN apt-get update && \
|
RUN apt-get update && \
|
||||||
apt-get install -y --no-install-recommends \
|
apt-get install -y --no-install-recommends \
|
||||||
build-essential \
|
build-essential \
|
||||||
|
|
@ -18,8 +18,7 @@ RUN apt-get update && \
|
||||||
rm -rf /var/lib/apt/lists/*
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
COPY backend/pyproject.toml ./
|
COPY backend/pyproject.toml ./
|
||||||
RUN pip install --user --no-warn-script-location .
|
RUN uv pip install --target /app/deps . gunicorn
|
||||||
RUN pip install --user --no-warn-script-location gunicorn
|
|
||||||
|
|
||||||
FROM python:3.13-slim
|
FROM python:3.13-slim
|
||||||
|
|
||||||
|
|
@ -32,8 +31,9 @@ RUN apt-get update && \
|
||||||
rm -rf /var/lib/apt/lists/* && \
|
rm -rf /var/lib/apt/lists/* && \
|
||||||
useradd -m -u 1000 appuser
|
useradd -m -u 1000 appuser
|
||||||
|
|
||||||
COPY --from=builder /root/.local /home/appuser/.local
|
COPY --from=builder /app/deps /home/appuser/.local/lib/python3.13/site-packages
|
||||||
ENV PATH=/home/appuser/.local/bin:$PATH
|
ENV PYTHONPATH=/home/appuser/.local/lib/python3.13/site-packages:$PYTHONPATH \
|
||||||
|
PATH=/home/appuser/.local/lib/python3.13/site-packages/bin:$PATH
|
||||||
|
|
||||||
COPY backend/ .
|
COPY backend/ .
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,23 +17,23 @@ interface MessageBubbleProps {
|
||||||
|
|
||||||
export function MessageBubble(props: MessageBubbleProps): JSX.Element {
|
export function MessageBubble(props: MessageBubbleProps): JSX.Element {
|
||||||
const bubbleClasses = (): string => {
|
const bubbleClasses = (): string => {
|
||||||
const base = "max-w-[80%] p-3"
|
const base = "max-w-[70%] p-4"
|
||||||
if (props.isOwnMessage) {
|
if (props.isOwnMessage) {
|
||||||
return `${base} bg-orange text-black ml-auto`
|
return `${base} bg-orange text-black`
|
||||||
}
|
}
|
||||||
return `${base} bg-black border-2 border-orange text-white`
|
return `${base} bg-black border-2 border-orange text-white`
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class={`flex ${props.isOwnMessage ? "justify-end" : "justify-start"} ${props.class ?? ""}`}>
|
<div class={`flex px-4 ${props.isOwnMessage ? "justify-end" : "justify-start"} ${props.class ?? ""}`}>
|
||||||
<div class={bubbleClasses()}>
|
<div class={bubbleClasses()}>
|
||||||
<Show when={props.showSender === true && !props.isOwnMessage}>
|
<Show when={props.showSender === true && !props.isOwnMessage}>
|
||||||
<div class="font-pixel text-[8px] text-orange mb-1">
|
<div class="font-pixel text-[10px] text-orange mb-2">
|
||||||
{props.message.sender_username}
|
{props.message.sender_username}
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<div class="font-pixel text-[10px] break-words whitespace-pre-wrap">
|
<div class="font-placeholder break-words whitespace-pre-wrap" style="font-size: 32px; line-height: 1.4;">
|
||||||
{props.message.content}
|
{props.message.content}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
/**
|
/**
|
||||||
* Main application shell with sidebar and content area
|
* Main application shell layout
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { ParentProps, JSX } from "solid-js"
|
import type { ParentProps, JSX } from "solid-js"
|
||||||
|
|
@ -21,59 +21,30 @@ export function AppShell(props: AppShellProps): JSX.Element {
|
||||||
const showSidebar = (): boolean => props.showSidebar ?? true
|
const showSidebar = (): boolean => props.showSidebar ?? true
|
||||||
const showHeader = (): boolean => props.showHeader ?? true
|
const showHeader = (): boolean => props.showHeader ?? true
|
||||||
|
|
||||||
const getSidebarClasses = (): string => {
|
|
||||||
if (isMobile()) {
|
|
||||||
return sidebarOpen() ? "fixed inset-y-0 left-0 z-40 w-72" : "hidden"
|
|
||||||
}
|
|
||||||
return sidebarOpen() ? "w-72" : "w-0 overflow-hidden"
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleBackdropClick = (): void => {
|
|
||||||
$sidebarOpen.set(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleBackdropKeyDown = (e: KeyboardEvent): void => {
|
|
||||||
if (e.key === "Enter" || e.key === " " || e.key === "Escape") {
|
|
||||||
e.preventDefault()
|
|
||||||
$sidebarOpen.set(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="h-screen flex flex-col bg-black overflow-hidden">
|
<div class="flex flex-col h-screen w-screen overflow-hidden bg-black">
|
||||||
<Show when={showHeader()}>
|
<Show when={showHeader()}>
|
||||||
<Header />
|
<Header />
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<div class="flex-1 flex overflow-hidden">
|
<div class="flex flex-1 overflow-hidden">
|
||||||
<Show when={showSidebar()}>
|
<Show when={showSidebar() && sidebarOpen()}>
|
||||||
<aside
|
<div class="w-72 h-full border-r-2 border-orange overflow-hidden flex-shrink-0">
|
||||||
class={`
|
|
||||||
flex-shrink-0 h-full
|
|
||||||
border-r-2 border-orange
|
|
||||||
transition-all duration-100
|
|
||||||
${getSidebarClasses()}
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
</aside>
|
</div>
|
||||||
|
|
||||||
<Show when={isMobile() && sidebarOpen()}>
|
|
||||||
<div
|
|
||||||
class="fixed inset-0 z-30 bg-black/80"
|
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
onClick={handleBackdropClick}
|
|
||||||
onKeyDown={handleBackdropKeyDown}
|
|
||||||
aria-label="Close sidebar"
|
|
||||||
/>
|
|
||||||
</Show>
|
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<main class="flex-1 overflow-hidden bg-black">
|
<main class="flex-1 overflow-hidden bg-black">
|
||||||
{props.children}
|
{props.children}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Show when={isMobile() && sidebarOpen()}>
|
||||||
|
<div
|
||||||
|
class="fixed inset-0 z-30 bg-black/80"
|
||||||
|
onClick={() => $sidebarOpen.set(false)}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,20 +4,24 @@
|
||||||
|
|
||||||
import type { JSX } from "solid-js"
|
import type { JSX } from "solid-js"
|
||||||
import { Show } from "solid-js"
|
import { Show } from "solid-js"
|
||||||
import { A } from "@solidjs/router"
|
import { A, useLocation } from "@solidjs/router"
|
||||||
import { useStore } from "@nanostores/solid"
|
import { useStore } from "@nanostores/solid"
|
||||||
import {
|
import {
|
||||||
$isAuthenticated,
|
$isAuthenticated,
|
||||||
$sidebarOpen,
|
$sidebarOpen,
|
||||||
|
$currentUser,
|
||||||
toggleSidebar,
|
toggleSidebar,
|
||||||
openModal,
|
openModal,
|
||||||
} from "../../stores"
|
} from "../../stores"
|
||||||
import { IconButton } from "../UI/IconButton"
|
import { IconButton } from "../UI/IconButton"
|
||||||
import { Badge } from "../UI/Badge"
|
import { Badge } from "../UI/Badge"
|
||||||
|
import { Avatar } from "../UI/Avatar"
|
||||||
|
|
||||||
export function Header(): JSX.Element {
|
export function Header(): JSX.Element {
|
||||||
const isAuthenticated = useStore($isAuthenticated)
|
const isAuthenticated = useStore($isAuthenticated)
|
||||||
const sidebarOpen = useStore($sidebarOpen)
|
const sidebarOpen = useStore($sidebarOpen)
|
||||||
|
const currentUser = useStore($currentUser)
|
||||||
|
const location = useLocation()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header class="flex-shrink-0 bg-black border-b-4 border-orange">
|
<header class="flex-shrink-0 bg-black border-b-4 border-orange">
|
||||||
|
|
@ -73,6 +77,21 @@ export function Header(): JSX.Element {
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => openModal("new-conversation")}
|
onClick={() => openModal("new-conversation")}
|
||||||
/>
|
/>
|
||||||
|
<Show when={currentUser()} keyed>
|
||||||
|
{(user) => (
|
||||||
|
<A
|
||||||
|
href="/settings"
|
||||||
|
class={`flex items-center gap-2 px-2 py-1 border-2 ${
|
||||||
|
location.pathname === "/settings"
|
||||||
|
? "border-orange bg-orange text-black"
|
||||||
|
: "border-transparent text-white hover:text-orange"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Avatar alt={user.display_name} size="xs" fallback={user.display_name.slice(0, 2)} />
|
||||||
|
<span class="font-pixel text-[10px] hidden sm:block">{user.display_name}</span>
|
||||||
|
</A>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
</Show>
|
</Show>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,10 @@
|
||||||
/**
|
/**
|
||||||
* 8-bit styled sidebar component
|
* Sidebar - Room list only
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { JSX } from "solid-js"
|
import type { JSX } from "solid-js"
|
||||||
import { Show, For } from "solid-js"
|
import { Show, Index, createMemo } from "solid-js"
|
||||||
import type { Room, Participant } from "../../types"
|
import type { Participant } from "../../types"
|
||||||
import { A, useLocation } from "@solidjs/router"
|
|
||||||
import { useStore } from "@nanostores/solid"
|
import { useStore } from "@nanostores/solid"
|
||||||
import {
|
import {
|
||||||
$currentUser,
|
$currentUser,
|
||||||
|
|
@ -24,40 +23,30 @@ export function Sidebar(): JSX.Element {
|
||||||
const rooms = useStore($rooms)
|
const rooms = useStore($rooms)
|
||||||
const activeRoomId = useStore($activeRoomId)
|
const activeRoomId = useStore($activeRoomId)
|
||||||
const totalUnread = useStore($totalUnreadCount)
|
const totalUnread = useStore($totalUnreadCount)
|
||||||
const location = useLocation()
|
|
||||||
|
|
||||||
const roomList = (): Room[] => {
|
const sortedRooms = createMemo(() => {
|
||||||
const roomsObj = rooms()
|
const arr = Object.values(rooms())
|
||||||
console.log("[Sidebar] rooms store:", roomsObj)
|
return arr.sort((a, b) => {
|
||||||
const roomArray: Room[] = Object.values(roomsObj)
|
|
||||||
console.log("[Sidebar] roomArray length:", roomArray.length)
|
|
||||||
return roomArray.sort((a, b) => {
|
|
||||||
const aTime = a.last_message?.created_at ?? a.updated_at
|
const aTime = a.last_message?.created_at ?? a.updated_at
|
||||||
const bTime = b.last_message?.created_at ?? b.updated_at
|
const bTime = b.last_message?.created_at ?? b.updated_at
|
||||||
return new Date(bTime).getTime() - new Date(aTime).getTime()
|
return new Date(bTime).getTime() - new Date(aTime).getTime()
|
||||||
})
|
})
|
||||||
}
|
})
|
||||||
|
|
||||||
const isActive = (path: string): boolean => location.pathname === path
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="h-full flex flex-col bg-black">
|
<div class="flex flex-col h-full bg-black">
|
||||||
<div class="p-4 border-b-2 border-orange">
|
<div class="p-4 border-b-2 border-orange">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<h2 class="font-pixel text-[10px] text-orange uppercase">
|
<h2 class="font-pixel text-[10px] text-orange uppercase">Messages</h2>
|
||||||
Messages
|
|
||||||
</h2>
|
|
||||||
<Show when={totalUnread() > 0}>
|
<Show when={totalUnread() > 0}>
|
||||||
<Badge variant="primary" size="xs">
|
<Badge variant="primary" size="xs">{totalUnread()}</Badge>
|
||||||
{totalUnread()}
|
|
||||||
</Badge>
|
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="p-2">
|
<div class="p-2">
|
||||||
<IconButton
|
<IconButton
|
||||||
icon={<NewChatIcon />}
|
icon={<PlusIcon />}
|
||||||
ariaLabel="New conversation"
|
ariaLabel="New conversation"
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|
@ -66,110 +55,66 @@ export function Sidebar(): JSX.Element {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav class="flex-1 overflow-y-auto scrollbar-pixel">
|
<div class="flex-1 overflow-y-auto p-2">
|
||||||
<div class="p-2 space-y-1">
|
<Index each={sortedRooms()}>
|
||||||
<For each={roomList()}>
|
{(room, idx) => {
|
||||||
{(room) => {
|
const isActive = createMemo(() => activeRoomId() === room().id)
|
||||||
const isSelected = (): boolean => activeRoomId() === room.id
|
const other = createMemo(() => room().participants?.find((p: Participant) => p.user_id !== currentUser()?.id))
|
||||||
const otherParticipant = (): Participant | undefined =>
|
const displayName = createMemo(() => room().name ?? other()?.display_name ?? "Chat")
|
||||||
room.participants?.find((p: Participant) => p.user_id !== currentUser()?.id)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setActiveRoom(room.id)}
|
data-room-id={room().id}
|
||||||
class={`
|
data-active={isActive()}
|
||||||
w-full flex items-center gap-3 p-3
|
onClick={() => setActiveRoom(room().id)}
|
||||||
border-2 transition-colors duration-100
|
style={{
|
||||||
${isSelected()
|
width: "100%",
|
||||||
? "bg-orange text-black border-orange"
|
display: "flex",
|
||||||
: "bg-black text-white border-transparent hover:border-orange"
|
"align-items": "center",
|
||||||
}
|
gap: "12px",
|
||||||
`}
|
padding: "12px",
|
||||||
>
|
"margin-bottom": "4px",
|
||||||
<Avatar
|
border: isActive() ? "2px solid #FF5300" : "2px solid transparent",
|
||||||
alt={room.name ?? otherParticipant()?.display_name ?? "Chat"}
|
background: isActive() ? "#FF5300" : "black",
|
||||||
size="sm"
|
color: isActive() ? "black" : "white",
|
||||||
fallback={room.name?.slice(0, 2) ?? otherParticipant()?.display_name?.slice(0, 2)}
|
cursor: "pointer",
|
||||||
/>
|
"text-align": "left",
|
||||||
<div class="flex-1 min-w-0 text-left">
|
}}
|
||||||
<div class="flex items-center justify-between">
|
>
|
||||||
<span class="font-pixel text-[10px] truncate">
|
<Avatar
|
||||||
{room.name ?? otherParticipant()?.display_name ?? "Chat"}
|
alt={displayName()}
|
||||||
</span>
|
size="sm"
|
||||||
<Show when={room.unread_count > 0}>
|
fallback={displayName().slice(0, 2)}
|
||||||
<Badge variant="primary" size="xs">
|
/>
|
||||||
{room.unread_count}
|
<div style={{ flex: 1, "min-width": 0 }}>
|
||||||
</Badge>
|
<span class="font-pixel text-[10px] truncate block">
|
||||||
</Show>
|
{displayName()}
|
||||||
</div>
|
</span>
|
||||||
<Show when={room.last_message} keyed>
|
<Show when={room().last_message}>
|
||||||
{(lastMsg) => (
|
<p
|
||||||
<p class={`font-pixel text-[8px] truncate mt-0.5 ${
|
class="font-pixel text-[8px] truncate mt-0.5"
|
||||||
isSelected() ? "text-black/70" : "text-gray"
|
style={{ color: isActive() ? "rgba(0,0,0,0.7)" : "#888" }}
|
||||||
}`}>
|
>
|
||||||
{lastMsg.content}
|
{room().last_message?.content}
|
||||||
</p>
|
</p>
|
||||||
)}
|
</Show>
|
||||||
</Show>
|
</div>
|
||||||
</div>
|
</button>
|
||||||
</button>
|
)
|
||||||
)
|
}}
|
||||||
}}
|
</Index>
|
||||||
</For>
|
<Show when={sortedRooms().length === 0}>
|
||||||
</div>
|
|
||||||
|
|
||||||
<Show when={roomList().length === 0}>
|
|
||||||
<div class="p-4 text-center">
|
<div class="p-4 text-center">
|
||||||
<p class="font-pixel text-[8px] text-gray">
|
<p class="font-pixel text-[8px] text-gray">NO CONVERSATIONS YET</p>
|
||||||
NO CONVERSATIONS YET
|
|
||||||
</p>
|
|
||||||
<p class="font-pixel text-[8px] text-gray mt-2">
|
|
||||||
START A NEW CHAT TO BEGIN
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</nav>
|
|
||||||
|
|
||||||
<div class="p-3 border-t-2 border-orange">
|
|
||||||
<Show when={currentUser()} keyed>
|
|
||||||
{(user) => (
|
|
||||||
<A
|
|
||||||
href="/settings"
|
|
||||||
class={`
|
|
||||||
flex items-center gap-3 p-2
|
|
||||||
border-2 transition-colors duration-100
|
|
||||||
${isActive("/settings")
|
|
||||||
? "bg-orange text-black border-orange"
|
|
||||||
: "border-transparent hover:border-orange"
|
|
||||||
}
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
<Avatar
|
|
||||||
alt={user.display_name}
|
|
||||||
size="sm"
|
|
||||||
fallback={user.display_name.slice(0, 2)}
|
|
||||||
/>
|
|
||||||
<div class="flex-1 min-w-0">
|
|
||||||
<span class="font-pixel text-[10px] truncate block">
|
|
||||||
{user.display_name}
|
|
||||||
</span>
|
|
||||||
<span class={`font-pixel text-[8px] ${
|
|
||||||
isActive("/settings") ? "text-black/70" : "text-gray"
|
|
||||||
}`}>
|
|
||||||
@{user.username}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<SettingsIcon />
|
|
||||||
</A>
|
|
||||||
)}
|
|
||||||
</Show>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function NewChatIcon(): JSX.Element {
|
function PlusIcon(): JSX.Element {
|
||||||
return (
|
return (
|
||||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||||
<rect x="7" y="2" width="2" height="12" />
|
<rect x="7" y="2" width="2" height="12" />
|
||||||
|
|
@ -177,16 +122,3 @@ function NewChatIcon(): JSX.Element {
|
||||||
</svg>
|
</svg>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function SettingsIcon(): JSX.Element {
|
|
||||||
return (
|
|
||||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" class="text-gray">
|
|
||||||
<rect x="7" y="1" width="2" height="2" />
|
|
||||||
<rect x="5" y="3" width="6" height="2" />
|
|
||||||
<rect x="7" y="5" width="2" height="2" />
|
|
||||||
<rect x="7" y="9" width="2" height="2" />
|
|
||||||
<rect x="5" y="11" width="6" height="2" />
|
|
||||||
<rect x="7" y="13" width="2" height="2" />
|
|
||||||
</svg>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,9 @@ import type {
|
||||||
OneTimePreKey,
|
OneTimePreKey,
|
||||||
DoubleRatchetState,
|
DoubleRatchetState,
|
||||||
EncryptedMessage,
|
EncryptedMessage,
|
||||||
|
X3DHHeader,
|
||||||
|
FullMessageHeader,
|
||||||
|
MessageHeader,
|
||||||
} from "../types"
|
} from "../types"
|
||||||
import { DEFAULT_ONE_TIME_PREKEY_COUNT } from "../types"
|
import { DEFAULT_ONE_TIME_PREKEY_COUNT } from "../types"
|
||||||
import {
|
import {
|
||||||
|
|
@ -32,7 +35,7 @@ import {
|
||||||
getLatestSignedPreKey,
|
getLatestSignedPreKey,
|
||||||
saveOneTimePreKeys,
|
saveOneTimePreKeys,
|
||||||
getUnusedOneTimePreKeys,
|
getUnusedOneTimePreKeys,
|
||||||
getOneTimePreKey,
|
getOneTimePreKeyByPublicKey,
|
||||||
markOneTimePreKeyUsed,
|
markOneTimePreKeyUsed,
|
||||||
saveRatchetState,
|
saveRatchetState,
|
||||||
getRatchetState,
|
getRatchetState,
|
||||||
|
|
@ -43,7 +46,8 @@ import { api } from "../lib/api-client"
|
||||||
import {
|
import {
|
||||||
base64ToBytes,
|
base64ToBytes,
|
||||||
bytesToBase64,
|
bytesToBase64,
|
||||||
generateX25519KeyPair,
|
importX25519PrivateKey,
|
||||||
|
importX25519PublicKey,
|
||||||
} from "./primitives"
|
} from "./primitives"
|
||||||
|
|
||||||
class CryptoService {
|
class CryptoService {
|
||||||
|
|
@ -51,6 +55,7 @@ class CryptoService {
|
||||||
private identityKeyPair: IdentityKeyPair | null = null
|
private identityKeyPair: IdentityKeyPair | null = null
|
||||||
private signedPreKey: SignedPreKey | null = null
|
private signedPreKey: SignedPreKey | null = null
|
||||||
private ratchetStates = new Map<string, DoubleRatchetState>()
|
private ratchetStates = new Map<string, DoubleRatchetState>()
|
||||||
|
private pendingX3DHHeaders = new Map<string, X3DHHeader>()
|
||||||
private initialized = false
|
private initialized = false
|
||||||
|
|
||||||
async initialize(userId: string): Promise<void> {
|
async initialize(userId: string): Promise<void> {
|
||||||
|
|
@ -114,9 +119,19 @@ class CryptoService {
|
||||||
await saveOneTimePreKeys(this.userId, newPreKeys)
|
await saveOneTimePreKeys(this.userId, newPreKeys)
|
||||||
}
|
}
|
||||||
|
|
||||||
private async uploadPublicKeys(_oneTimePreKeys: OneTimePreKey[]): Promise<void> {
|
private async uploadPublicKeys(oneTimePreKeys: OneTimePreKey[]): Promise<void> {
|
||||||
if (!this.userId) throw new Error("User ID not set")
|
if (!this.userId) throw new Error("User ID not set")
|
||||||
await api.encryption.initializeKeys(this.userId)
|
if (!this.identityKeyPair || !this.signedPreKey) {
|
||||||
|
throw new Error("Keys not generated")
|
||||||
|
}
|
||||||
|
|
||||||
|
await api.encryption.uploadKeys(this.userId, {
|
||||||
|
identity_key: this.identityKeyPair.x25519_public,
|
||||||
|
identity_key_ed25519: this.identityKeyPair.ed25519_public,
|
||||||
|
signed_prekey: this.signedPreKey.public_key,
|
||||||
|
signed_prekey_signature: this.signedPreKey.signature,
|
||||||
|
one_time_prekeys: oneTimePreKeys.map((k) => k.public_key),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async establishSession(peerId: string): Promise<void> {
|
async establishSession(peerId: string): Promise<void> {
|
||||||
|
|
@ -129,16 +144,23 @@ class CryptoService {
|
||||||
|
|
||||||
const x3dhResult = await initiateX3DH(this.identityKeyPair, peerBundle)
|
const x3dhResult = await initiateX3DH(this.identityKeyPair, peerBundle)
|
||||||
|
|
||||||
const peerIdentityKey = base64ToBytes(peerBundle.identity_key)
|
const peerSignedPrekey = base64ToBytes(peerBundle.signed_prekey)
|
||||||
|
|
||||||
const ratchetState = await initializeRatchetSender(
|
const ratchetState = await initializeRatchetSender(
|
||||||
peerId,
|
peerId,
|
||||||
x3dhResult.shared_key,
|
x3dhResult.shared_key,
|
||||||
peerIdentityKey
|
peerSignedPrekey
|
||||||
)
|
)
|
||||||
|
|
||||||
this.ratchetStates.set(peerId, ratchetState)
|
this.ratchetStates.set(peerId, ratchetState)
|
||||||
|
|
||||||
|
const x3dhHeader: X3DHHeader = {
|
||||||
|
identity_key: this.identityKeyPair.x25519_public,
|
||||||
|
ephemeral_key: x3dhResult.ephemeral_public_key,
|
||||||
|
one_time_prekey_id: x3dhResult.used_one_time_prekey ? peerBundle.one_time_prekey : null,
|
||||||
|
}
|
||||||
|
this.pendingX3DHHeaders.set(peerId, x3dhHeader)
|
||||||
|
|
||||||
const serialized = await serializeRatchetState(ratchetState)
|
const serialized = await serializeRatchetState(ratchetState)
|
||||||
await saveRatchetState(serialized)
|
await saveRatchetState(serialized)
|
||||||
}
|
}
|
||||||
|
|
@ -147,17 +169,17 @@ class CryptoService {
|
||||||
peerId: string,
|
peerId: string,
|
||||||
senderIdentityKey: string,
|
senderIdentityKey: string,
|
||||||
ephemeralKey: string,
|
ephemeralKey: string,
|
||||||
oneTimePreKeyId: string | null
|
oneTimePreKeyPublic: string | null
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (this.identityKeyPair === null || this.signedPreKey === null) {
|
if (this.identityKeyPair === null || this.signedPreKey === null) {
|
||||||
throw new Error("Keys not initialized")
|
throw new Error("Keys not initialized")
|
||||||
}
|
}
|
||||||
|
|
||||||
let oneTimePreKey: OneTimePreKey | null = null
|
let oneTimePreKey: OneTimePreKey | null = null
|
||||||
if (oneTimePreKeyId !== null) {
|
if (oneTimePreKeyPublic !== null) {
|
||||||
oneTimePreKey = await getOneTimePreKey(oneTimePreKeyId)
|
oneTimePreKey = await getOneTimePreKeyByPublicKey(oneTimePreKeyPublic)
|
||||||
if (oneTimePreKey !== null) {
|
if (oneTimePreKey !== null) {
|
||||||
await markOneTimePreKeyUsed(oneTimePreKeyId)
|
await markOneTimePreKeyUsed(oneTimePreKey.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -169,12 +191,21 @@ class CryptoService {
|
||||||
ephemeralKey
|
ephemeralKey
|
||||||
)
|
)
|
||||||
|
|
||||||
const dhKeyPair = await generateX25519KeyPair()
|
const signedPreKeyPrivate = await importX25519PrivateKey(
|
||||||
|
base64ToBytes(this.signedPreKey.private_key)
|
||||||
|
)
|
||||||
|
const signedPreKeyPublic = await importX25519PublicKey(
|
||||||
|
base64ToBytes(this.signedPreKey.public_key)
|
||||||
|
)
|
||||||
|
const signedPreKeyPair: CryptoKeyPair = {
|
||||||
|
privateKey: signedPreKeyPrivate,
|
||||||
|
publicKey: signedPreKeyPublic,
|
||||||
|
}
|
||||||
|
|
||||||
const ratchetState = await initializeRatchetReceiver(
|
const ratchetState = await initializeRatchetReceiver(
|
||||||
peerId,
|
peerId,
|
||||||
sharedKey,
|
sharedKey,
|
||||||
dhKeyPair
|
signedPreKeyPair
|
||||||
)
|
)
|
||||||
|
|
||||||
this.ratchetStates.set(peerId, ratchetState)
|
this.ratchetStates.set(peerId, ratchetState)
|
||||||
|
|
@ -200,10 +231,20 @@ class CryptoService {
|
||||||
const serialized = await serializeRatchetState(state)
|
const serialized = await serializeRatchetState(state)
|
||||||
await saveRatchetState(serialized)
|
await saveRatchetState(serialized)
|
||||||
|
|
||||||
|
const pendingX3DH = this.pendingX3DHHeaders.get(peerId)
|
||||||
|
const fullHeader: FullMessageHeader = {
|
||||||
|
ratchet: encrypted.header,
|
||||||
|
x3dh: pendingX3DH ?? undefined,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pendingX3DH) {
|
||||||
|
this.pendingX3DHHeaders.delete(peerId)
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ciphertext: bytesToBase64(encrypted.ciphertext),
|
ciphertext: bytesToBase64(encrypted.ciphertext),
|
||||||
nonce: bytesToBase64(encrypted.nonce),
|
nonce: bytesToBase64(encrypted.nonce),
|
||||||
header: JSON.stringify(encrypted.header),
|
header: JSON.stringify(fullHeader),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -215,19 +256,34 @@ class CryptoService {
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
let state = await this.getRatchetState(peerId)
|
let state = await this.getRatchetState(peerId)
|
||||||
|
|
||||||
const parsedHeader = JSON.parse(header) as EncryptedMessage["header"]
|
const parsedHeader = JSON.parse(header) as FullMessageHeader | MessageHeader
|
||||||
|
|
||||||
|
let ratchetHeader: MessageHeader
|
||||||
|
let x3dhHeader: X3DHHeader | undefined
|
||||||
|
|
||||||
|
if ("ratchet" in parsedHeader) {
|
||||||
|
ratchetHeader = parsedHeader.ratchet
|
||||||
|
x3dhHeader = parsedHeader.x3dh
|
||||||
|
} else {
|
||||||
|
ratchetHeader = parsedHeader
|
||||||
|
}
|
||||||
|
|
||||||
const encryptedMessage: EncryptedMessage = {
|
const encryptedMessage: EncryptedMessage = {
|
||||||
ciphertext: base64ToBytes(ciphertext),
|
ciphertext: base64ToBytes(ciphertext),
|
||||||
nonce: base64ToBytes(nonce),
|
nonce: base64ToBytes(nonce),
|
||||||
header: parsedHeader,
|
header: ratchetHeader,
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state === null) {
|
if (state === null) {
|
||||||
|
if (!x3dhHeader) {
|
||||||
|
throw new Error("Cannot establish session: missing X3DH header")
|
||||||
|
}
|
||||||
|
|
||||||
await this.handleIncomingSession(
|
await this.handleIncomingSession(
|
||||||
peerId,
|
peerId,
|
||||||
parsedHeader.dh_public_key,
|
x3dhHeader.identity_key,
|
||||||
parsedHeader.dh_public_key,
|
x3dhHeader.ephemeral_key,
|
||||||
null
|
x3dhHeader.one_time_prekey_id
|
||||||
)
|
)
|
||||||
state = await this.getRatchetState(peerId)
|
state = await this.getRatchetState(peerId)
|
||||||
}
|
}
|
||||||
|
|
@ -276,6 +332,23 @@ class CryptoService {
|
||||||
isInitialized(): boolean {
|
isInitialized(): boolean {
|
||||||
return this.initialized
|
return this.initialized
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async resetAllSessions(): Promise<void> {
|
||||||
|
this.ratchetStates.clear()
|
||||||
|
this.pendingX3DHHeaders.clear()
|
||||||
|
|
||||||
|
const database = indexedDB.open("encrypted-chat-keys", 1)
|
||||||
|
database.onsuccess = () => {
|
||||||
|
const db = database.result
|
||||||
|
const tx = db.transaction("ratchet_states", "readwrite")
|
||||||
|
tx.objectStore("ratchet_states").clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const cryptoService = new CryptoService()
|
export const cryptoService = new CryptoService()
|
||||||
|
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
(window as unknown as { resetCryptoSessions: () => Promise<void> }).resetCryptoSessions =
|
||||||
|
() => cryptoService.resetAllSessions()
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
// ===================
|
// ===================
|
||||||
export * from "./primitives"
|
export * from "./primitives"
|
||||||
export * from "./key-store"
|
export * from "./key-store"
|
||||||
|
export * from "./message-store"
|
||||||
export * from "./x3dh"
|
export * from "./x3dh"
|
||||||
export * from "./double-ratchet"
|
export * from "./double-ratchet"
|
||||||
export * from "./crypto-service"
|
export * from "./crypto-service"
|
||||||
|
|
|
||||||
|
|
@ -266,6 +266,36 @@ export async function getOneTimePreKey(id: string): Promise<OneTimePreKey | null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getOneTimePreKeyByPublicKey(publicKey: string): Promise<OneTimePreKey | null> {
|
||||||
|
const database = await openDatabase()
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const transaction = database.transaction(STORES.ONE_TIME_PREKEYS, "readonly")
|
||||||
|
const store = transaction.objectStore(STORES.ONE_TIME_PREKEYS)
|
||||||
|
const request = store.getAll()
|
||||||
|
|
||||||
|
request.onsuccess = () => {
|
||||||
|
const results = request.result as StoredOneTimePreKey[]
|
||||||
|
const match = results.find((r) => r.public_key === publicKey)
|
||||||
|
|
||||||
|
if (!match) {
|
||||||
|
resolve(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve({
|
||||||
|
id: match.id,
|
||||||
|
private_key: match.private_key,
|
||||||
|
public_key: match.public_key,
|
||||||
|
is_used: match.is_used,
|
||||||
|
created_at: match.created_at,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
request.onerror = () => reject(new Error(request.error?.message ?? "Failed to find one-time prekey"))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export async function getUnusedOneTimePreKeys(userId: string): Promise<OneTimePreKey[]> {
|
export async function getUnusedOneTimePreKeys(userId: string): Promise<OneTimePreKey[]> {
|
||||||
const database = await openDatabase()
|
const database = await openDatabase()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,211 @@
|
||||||
|
// ===================
|
||||||
|
// © AngelaMos | 2025
|
||||||
|
// message-store.ts
|
||||||
|
// ===================
|
||||||
|
import type { Message } from "../types"
|
||||||
|
|
||||||
|
const DB_NAME = "encrypted-chat-messages"
|
||||||
|
const DB_VERSION = 1
|
||||||
|
|
||||||
|
const STORES = {
|
||||||
|
MESSAGES: "decrypted_messages",
|
||||||
|
} as const
|
||||||
|
|
||||||
|
let db: IDBDatabase | null = null
|
||||||
|
|
||||||
|
async function openDatabase(): Promise<IDBDatabase> {
|
||||||
|
if (db !== null) return db
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const request = indexedDB.open(DB_NAME, DB_VERSION)
|
||||||
|
|
||||||
|
request.onerror = () => {
|
||||||
|
reject(new Error("Failed to open message database"))
|
||||||
|
}
|
||||||
|
|
||||||
|
request.onsuccess = () => {
|
||||||
|
db = request.result
|
||||||
|
resolve(db)
|
||||||
|
}
|
||||||
|
|
||||||
|
request.onupgradeneeded = (event) => {
|
||||||
|
const database = (event.target as IDBOpenDBRequest).result
|
||||||
|
|
||||||
|
if (!database.objectStoreNames.contains(STORES.MESSAGES)) {
|
||||||
|
const messageStore = database.createObjectStore(STORES.MESSAGES, { keyPath: "id" })
|
||||||
|
messageStore.createIndex("room_id", "room_id", { unique: false })
|
||||||
|
messageStore.createIndex("created_at", "created_at", { unique: false })
|
||||||
|
messageStore.createIndex("room_created", ["room_id", "created_at"], { unique: false })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function performTransaction<T>(
|
||||||
|
storeName: string,
|
||||||
|
mode: IDBTransactionMode,
|
||||||
|
operation: (store: IDBObjectStore) => IDBRequest<T>
|
||||||
|
): Promise<T> {
|
||||||
|
const database = await openDatabase()
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const transaction = database.transaction(storeName, mode)
|
||||||
|
const store = transaction.objectStore(storeName)
|
||||||
|
const request = operation(store)
|
||||||
|
|
||||||
|
request.onsuccess = () => resolve(request.result)
|
||||||
|
request.onerror = () => reject(new Error(request.error?.message ?? "Database operation failed"))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveDecryptedMessage(message: Message): Promise<void> {
|
||||||
|
await performTransaction(
|
||||||
|
STORES.MESSAGES,
|
||||||
|
"readwrite",
|
||||||
|
(store) => store.put(message)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveDecryptedMessages(messages: Message[]): Promise<void> {
|
||||||
|
const database = await openDatabase()
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const transaction = database.transaction(STORES.MESSAGES, "readwrite")
|
||||||
|
const store = transaction.objectStore(STORES.MESSAGES)
|
||||||
|
|
||||||
|
transaction.oncomplete = () => resolve()
|
||||||
|
transaction.onerror = () => reject(new Error(transaction.error?.message ?? "Failed to save messages"))
|
||||||
|
|
||||||
|
for (const message of messages) {
|
||||||
|
store.put(message)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getDecryptedMessage(messageId: string): Promise<Message | null> {
|
||||||
|
const result = await performTransaction<Message | undefined>(
|
||||||
|
STORES.MESSAGES,
|
||||||
|
"readonly",
|
||||||
|
(store) => store.get(messageId) as IDBRequest<Message | undefined>
|
||||||
|
)
|
||||||
|
|
||||||
|
return result ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getDecryptedMessages(roomId: string, limit?: number): Promise<Message[]> {
|
||||||
|
const database = await openDatabase()
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const transaction = database.transaction(STORES.MESSAGES, "readonly")
|
||||||
|
const store = transaction.objectStore(STORES.MESSAGES)
|
||||||
|
const index = store.index("room_id")
|
||||||
|
const request = index.getAll(roomId)
|
||||||
|
|
||||||
|
request.onsuccess = () => {
|
||||||
|
let results = request.result as Message[]
|
||||||
|
results = results.sort(
|
||||||
|
(a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime()
|
||||||
|
)
|
||||||
|
|
||||||
|
if (limit !== undefined && limit > 0) {
|
||||||
|
results = results.slice(-limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve(results)
|
||||||
|
}
|
||||||
|
|
||||||
|
request.onerror = () => reject(new Error(request.error?.message ?? "Failed to get messages"))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getLatestMessageTimestamp(roomId: string): Promise<string | null> {
|
||||||
|
const database = await openDatabase()
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const transaction = database.transaction(STORES.MESSAGES, "readonly")
|
||||||
|
const store = transaction.objectStore(STORES.MESSAGES)
|
||||||
|
const index = store.index("room_created")
|
||||||
|
const range = IDBKeyRange.bound([roomId, ""], [roomId, "\uffff"])
|
||||||
|
const request = index.openCursor(range, "prev")
|
||||||
|
|
||||||
|
request.onsuccess = () => {
|
||||||
|
const cursor = request.result
|
||||||
|
if (cursor !== null) {
|
||||||
|
const message = cursor.value as Message
|
||||||
|
resolve(message.created_at)
|
||||||
|
} else {
|
||||||
|
resolve(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
request.onerror = () => reject(new Error(request.error?.message ?? "Failed to get latest timestamp"))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteMessage(messageId: string): Promise<void> {
|
||||||
|
await performTransaction(
|
||||||
|
STORES.MESSAGES,
|
||||||
|
"readwrite",
|
||||||
|
(store) => store.delete(messageId)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateMessageId(oldId: string, newId: string): Promise<void> {
|
||||||
|
const message = await getDecryptedMessage(oldId)
|
||||||
|
if (message) {
|
||||||
|
await deleteMessage(oldId)
|
||||||
|
message.id = newId
|
||||||
|
await saveDecryptedMessage(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearRoomMessages(roomId: string): Promise<void> {
|
||||||
|
const database = await openDatabase()
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const transaction = database.transaction(STORES.MESSAGES, "readwrite")
|
||||||
|
const store = transaction.objectStore(STORES.MESSAGES)
|
||||||
|
const index = store.index("room_id")
|
||||||
|
const request = index.openCursor(IDBKeyRange.only(roomId))
|
||||||
|
|
||||||
|
request.onsuccess = () => {
|
||||||
|
const cursor = request.result
|
||||||
|
if (cursor !== null) {
|
||||||
|
cursor.delete()
|
||||||
|
cursor.continue()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
transaction.oncomplete = () => resolve()
|
||||||
|
transaction.onerror = () => reject(new Error(transaction.error?.message ?? "Failed to clear room messages"))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearAllMessages(): Promise<void> {
|
||||||
|
await performTransaction(
|
||||||
|
STORES.MESSAGES,
|
||||||
|
"readwrite",
|
||||||
|
(store) => store.clear()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getMessageCount(roomId: string): Promise<number> {
|
||||||
|
const database = await openDatabase()
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const transaction = database.transaction(STORES.MESSAGES, "readonly")
|
||||||
|
const store = transaction.objectStore(STORES.MESSAGES)
|
||||||
|
const index = store.index("room_id")
|
||||||
|
const request = index.count(IDBKeyRange.only(roomId))
|
||||||
|
|
||||||
|
request.onsuccess = () => resolve(request.result)
|
||||||
|
request.onerror = () => reject(new Error(request.error?.message ?? "Failed to count messages"))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function closeDatabase(): void {
|
||||||
|
if (db !== null) {
|
||||||
|
db.close()
|
||||||
|
db = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -39,36 +39,50 @@ export async function x25519DeriveSharedSecret(
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function importX25519PublicKey(keyBytes: Uint8Array): Promise<CryptoKey> {
|
export async function importX25519PublicKey(keyBytes: Uint8Array): Promise<CryptoKey> {
|
||||||
|
if (keyBytes.length === 32) {
|
||||||
|
return await subtle.importKey(
|
||||||
|
"raw",
|
||||||
|
keyBytes.buffer as ArrayBuffer,
|
||||||
|
{ name: "X25519" },
|
||||||
|
true,
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
}
|
||||||
return await subtle.importKey(
|
return await subtle.importKey(
|
||||||
"raw",
|
"spki",
|
||||||
keyBytes.buffer as ArrayBuffer,
|
keyBytes.buffer as ArrayBuffer,
|
||||||
{
|
{ name: "X25519" },
|
||||||
name: "X25519",
|
|
||||||
},
|
|
||||||
true,
|
true,
|
||||||
[]
|
[]
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function importX25519PrivateKey(keyBytes: Uint8Array): Promise<CryptoKey> {
|
export async function importX25519PrivateKey(keyBytes: Uint8Array): Promise<CryptoKey> {
|
||||||
|
if (keyBytes.length === 32) {
|
||||||
|
return await subtle.importKey(
|
||||||
|
"raw",
|
||||||
|
keyBytes.buffer as ArrayBuffer,
|
||||||
|
{ name: "X25519" },
|
||||||
|
true,
|
||||||
|
["deriveBits"]
|
||||||
|
)
|
||||||
|
}
|
||||||
return await subtle.importKey(
|
return await subtle.importKey(
|
||||||
"raw",
|
"pkcs8",
|
||||||
keyBytes.buffer as ArrayBuffer,
|
keyBytes.buffer as ArrayBuffer,
|
||||||
{
|
{ name: "X25519" },
|
||||||
name: "X25519",
|
|
||||||
},
|
|
||||||
true,
|
true,
|
||||||
["deriveBits"]
|
["deriveBits"]
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function exportPublicKey(key: CryptoKey): Promise<Uint8Array> {
|
export async function exportPublicKey(key: CryptoKey): Promise<Uint8Array> {
|
||||||
const exported = await subtle.exportKey("raw", key)
|
const exported = await subtle.exportKey("spki", key)
|
||||||
return new Uint8Array(exported)
|
return new Uint8Array(exported)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function exportPrivateKey(key: CryptoKey): Promise<Uint8Array> {
|
export async function exportPrivateKey(key: CryptoKey): Promise<Uint8Array> {
|
||||||
const exported = await subtle.exportKey("raw", key)
|
const exported = await subtle.exportKey("pkcs8", key)
|
||||||
return new Uint8Array(exported)
|
return new Uint8Array(exported)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -112,24 +126,38 @@ export async function ed25519Verify(
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function importEd25519PublicKey(keyBytes: Uint8Array): Promise<CryptoKey> {
|
export async function importEd25519PublicKey(keyBytes: Uint8Array): Promise<CryptoKey> {
|
||||||
|
if (keyBytes.length === 32) {
|
||||||
|
return await subtle.importKey(
|
||||||
|
"raw",
|
||||||
|
keyBytes.buffer as ArrayBuffer,
|
||||||
|
{ name: "Ed25519" },
|
||||||
|
true,
|
||||||
|
["verify"]
|
||||||
|
)
|
||||||
|
}
|
||||||
return await subtle.importKey(
|
return await subtle.importKey(
|
||||||
"raw",
|
"spki",
|
||||||
keyBytes.buffer as ArrayBuffer,
|
keyBytes.buffer as ArrayBuffer,
|
||||||
{
|
{ name: "Ed25519" },
|
||||||
name: "Ed25519",
|
|
||||||
},
|
|
||||||
true,
|
true,
|
||||||
["verify"]
|
["verify"]
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function importEd25519PrivateKey(keyBytes: Uint8Array): Promise<CryptoKey> {
|
export async function importEd25519PrivateKey(keyBytes: Uint8Array): Promise<CryptoKey> {
|
||||||
|
if (keyBytes.length === 32) {
|
||||||
|
return await subtle.importKey(
|
||||||
|
"raw",
|
||||||
|
keyBytes.buffer as ArrayBuffer,
|
||||||
|
{ name: "Ed25519" },
|
||||||
|
true,
|
||||||
|
["sign"]
|
||||||
|
)
|
||||||
|
}
|
||||||
return await subtle.importKey(
|
return await subtle.importKey(
|
||||||
"raw",
|
"pkcs8",
|
||||||
keyBytes.buffer as ArrayBuffer,
|
keyBytes.buffer as ArrayBuffer,
|
||||||
{
|
{ name: "Ed25519" },
|
||||||
name: "Ed25519",
|
|
||||||
},
|
|
||||||
true,
|
true,
|
||||||
["sign"]
|
["sign"]
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -117,7 +117,7 @@ export async function initiateX3DH(
|
||||||
recipientBundle: PreKeyBundle
|
recipientBundle: PreKeyBundle
|
||||||
): Promise<X3DHResult> {
|
): Promise<X3DHResult> {
|
||||||
const signatureValid = await verifySignedPreKey(
|
const signatureValid = await verifySignedPreKey(
|
||||||
recipientBundle.identity_key,
|
recipientBundle.identity_key_ed25519,
|
||||||
recipientBundle.signed_prekey,
|
recipientBundle.signed_prekey,
|
||||||
recipientBundle.signed_prekey_signature
|
recipientBundle.signed_prekey_signature
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ import {
|
||||||
wsManager,
|
wsManager,
|
||||||
} from "../websocket"
|
} from "../websocket"
|
||||||
import { roomService } from "../services"
|
import { roomService } from "../services"
|
||||||
|
import { cryptoService, saveDecryptedMessage } from "../crypto"
|
||||||
|
|
||||||
export default function Chat(): JSX.Element {
|
export default function Chat(): JSX.Element {
|
||||||
const activeRoom = useStore($activeRoom)
|
const activeRoom = useStore($activeRoom)
|
||||||
|
|
@ -47,14 +48,13 @@ export default function Chat(): JSX.Element {
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
const currentUserId = userId()
|
const currentUserId = userId()
|
||||||
console.log("[Chat] onMount - userId:", currentUserId)
|
|
||||||
if (currentUserId) {
|
if (currentUserId) {
|
||||||
console.log("[Chat] Connecting WebSocket and loading rooms...")
|
try {
|
||||||
|
await cryptoService.initialize(currentUserId)
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
connectWebSocket()
|
connectWebSocket()
|
||||||
await roomService.loadRooms(currentUserId)
|
await roomService.loadRooms(currentUserId)
|
||||||
console.log("[Chat] Rooms loaded")
|
|
||||||
} else {
|
|
||||||
console.log("[Chat] No userId, skipping room load")
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -62,7 +62,7 @@ export default function Chat(): JSX.Element {
|
||||||
disconnectWebSocket()
|
disconnectWebSocket()
|
||||||
})
|
})
|
||||||
|
|
||||||
const handleSendMessage = (content: string): void => {
|
const handleSendMessage = async (content: string): Promise<void> => {
|
||||||
const roomId = activeRoomId()
|
const roomId = activeRoomId()
|
||||||
const room = activeRoom()
|
const room = activeRoom()
|
||||||
const currentUserId = userId()
|
const currentUserId = userId()
|
||||||
|
|
@ -88,27 +88,37 @@ export default function Chat(): JSX.Element {
|
||||||
const tempId = `temp-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
const tempId = `temp-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||||
const now = new Date().toISOString()
|
const now = new Date().toISOString()
|
||||||
|
|
||||||
addMessage(roomId, {
|
const messageToSend = {
|
||||||
id: tempId,
|
id: tempId,
|
||||||
room_id: roomId,
|
room_id: roomId,
|
||||||
sender_id: currentUserId,
|
sender_id: currentUserId,
|
||||||
sender_username: user?.username ?? "me",
|
sender_username: user?.username ?? "me",
|
||||||
content,
|
content,
|
||||||
status: "sending",
|
status: "sending" as const,
|
||||||
is_encrypted: false,
|
is_encrypted: true,
|
||||||
created_at: now,
|
created_at: now,
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
})
|
}
|
||||||
|
|
||||||
const sent = wsManager.sendEncryptedMessage(
|
addMessage(roomId, messageToSend)
|
||||||
recipientId,
|
|
||||||
roomId,
|
|
||||||
content,
|
|
||||||
tempId
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!sent) {
|
try {
|
||||||
showToast("error", "SEND FAILED", "NOT CONNECTED")
|
const encrypted = await cryptoService.encrypt(recipientId, content)
|
||||||
|
const sent = wsManager.sendEncryptedMessage(
|
||||||
|
recipientId,
|
||||||
|
roomId,
|
||||||
|
encrypted,
|
||||||
|
tempId
|
||||||
|
)
|
||||||
|
|
||||||
|
if (sent) {
|
||||||
|
void saveDecryptedMessage(messageToSend)
|
||||||
|
} else {
|
||||||
|
showToast("error", "SEND FAILED", "NOT CONNECTED")
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[Chat] Encryption failed:", error)
|
||||||
|
showToast("error", "SEND FAILED", "ENCRYPTION ERROR")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import type {
|
||||||
} from "../types"
|
} from "../types"
|
||||||
import { isPublicKeyCredential } from "../types/guards"
|
import { isPublicKeyCredential } from "../types/guards"
|
||||||
import { setCurrentUser, logout as storeLogout } from "../stores"
|
import { setCurrentUser, logout as storeLogout } from "../stores"
|
||||||
|
import { cryptoService } from "../crypto"
|
||||||
|
|
||||||
interface PublicKeyCredentialStatic {
|
interface PublicKeyCredentialStatic {
|
||||||
isUserVerifyingPlatformAuthenticatorAvailable(): Promise<boolean>
|
isUserVerifyingPlatformAuthenticatorAvailable(): Promise<boolean>
|
||||||
|
|
@ -131,6 +132,7 @@ export async function register(
|
||||||
})
|
})
|
||||||
|
|
||||||
setCurrentUser(user)
|
setCurrentUser(user)
|
||||||
|
await cryptoService.initialize(user.id)
|
||||||
|
|
||||||
return user
|
return user
|
||||||
}
|
}
|
||||||
|
|
@ -161,6 +163,7 @@ export async function login(username?: string): Promise<User> {
|
||||||
})
|
})
|
||||||
|
|
||||||
setCurrentUser(user)
|
setCurrentUser(user)
|
||||||
|
await cryptoService.initialize(user.id)
|
||||||
|
|
||||||
return user
|
return user
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,18 +10,21 @@ import {
|
||||||
addRoom,
|
addRoom,
|
||||||
setRoomMessages,
|
setRoomMessages,
|
||||||
setHasMore,
|
setHasMore,
|
||||||
|
$userId,
|
||||||
} from "../stores"
|
} from "../stores"
|
||||||
|
import {
|
||||||
|
getDecryptedMessages,
|
||||||
|
getDecryptedMessage,
|
||||||
|
saveDecryptedMessage,
|
||||||
|
cryptoService,
|
||||||
|
} from "../crypto"
|
||||||
|
|
||||||
export async function loadRooms(userId: string): Promise<Room[]> {
|
export async function loadRooms(userId: string): Promise<Room[]> {
|
||||||
console.log("[RoomService] loadRooms called with userId:", userId)
|
|
||||||
try {
|
try {
|
||||||
const response = await api.rooms.list(userId)
|
const response = await api.rooms.list(userId)
|
||||||
console.log("[RoomService] API response:", response)
|
|
||||||
console.log("[RoomService] rooms count:", response.rooms.length)
|
|
||||||
setRooms(response.rooms)
|
setRooms(response.rooms)
|
||||||
return response.rooms
|
return response.rooms
|
||||||
} catch (err) {
|
} catch {
|
||||||
console.error("[RoomService] Failed to load rooms:", err)
|
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -51,28 +54,85 @@ export async function loadMessages(
|
||||||
offset: number = 0
|
offset: number = 0
|
||||||
): Promise<Message[]> {
|
): Promise<Message[]> {
|
||||||
try {
|
try {
|
||||||
|
const localMessages = await getDecryptedMessages(roomId, limit)
|
||||||
|
const localMessageIds = new Set(localMessages.map((m) => m.id))
|
||||||
|
|
||||||
|
if (localMessages.length > 0) {
|
||||||
|
setRoomMessages(roomId, localMessages)
|
||||||
|
}
|
||||||
|
|
||||||
const response = await api.rooms.getMessages(roomId, limit, offset)
|
const response = await api.rooms.getMessages(roomId, limit, offset)
|
||||||
const messages: Message[] = response.messages.map((msg) => ({
|
const serverMessages = response.messages.reverse()
|
||||||
id: msg.id,
|
|
||||||
room_id: msg.room_id,
|
const newMessages: Message[] = []
|
||||||
sender_id: msg.sender_id,
|
|
||||||
sender_username: msg.sender_username,
|
const currentUserId = $userId.get()
|
||||||
content: "[Encrypted message]",
|
|
||||||
status: "delivered" as const,
|
for (const msg of serverMessages) {
|
||||||
is_encrypted: true,
|
if (localMessageIds.has(msg.id)) {
|
||||||
encrypted_content: msg.ciphertext,
|
continue
|
||||||
nonce: msg.nonce,
|
}
|
||||||
header: msg.header,
|
|
||||||
created_at: msg.created_at,
|
let content = "[Encrypted - from another session]"
|
||||||
updated_at: msg.created_at,
|
const isOwnMessage = msg.sender_id === currentUserId
|
||||||
}))
|
|
||||||
const reversedMessages = messages.reverse()
|
if (isOwnMessage) {
|
||||||
setRoomMessages(roomId, reversedMessages)
|
const localCopy = await getDecryptedMessage(msg.id)
|
||||||
|
if (localCopy) {
|
||||||
|
content = localCopy.content
|
||||||
|
} else {
|
||||||
|
content = "[Your message - not stored locally]"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
content = await cryptoService.decrypt(
|
||||||
|
msg.sender_id,
|
||||||
|
msg.ciphertext,
|
||||||
|
msg.nonce,
|
||||||
|
msg.header
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
content = "[Encrypted - from another session]"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const decryptedMessage: Message = {
|
||||||
|
id: msg.id,
|
||||||
|
room_id: msg.room_id,
|
||||||
|
sender_id: msg.sender_id,
|
||||||
|
sender_username: msg.sender_username,
|
||||||
|
content,
|
||||||
|
status: "delivered" as const,
|
||||||
|
is_encrypted: true,
|
||||||
|
encrypted_content: msg.ciphertext,
|
||||||
|
nonce: msg.nonce,
|
||||||
|
header: msg.header,
|
||||||
|
created_at: msg.created_at,
|
||||||
|
updated_at: msg.created_at,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!content.startsWith("[Encrypted") && !content.startsWith("[Your message")) {
|
||||||
|
void saveDecryptedMessage(decryptedMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
newMessages.push(decryptedMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
const allMessages = [...localMessages, ...newMessages]
|
||||||
|
const uniqueMessages = Array.from(
|
||||||
|
new Map(allMessages.map((m) => [m.id, m])).values()
|
||||||
|
).sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime())
|
||||||
|
|
||||||
|
setRoomMessages(roomId, uniqueMessages)
|
||||||
setHasMore(roomId, response.has_more)
|
setHasMore(roomId, response.has_more)
|
||||||
return reversedMessages
|
return uniqueMessages
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[RoomService] Failed to load messages:", err)
|
console.error("[RoomService] Failed to load messages:", err)
|
||||||
return []
|
const localMessages = await getDecryptedMessages(roomId, limit)
|
||||||
|
if (localMessages.length > 0) {
|
||||||
|
setRoomMessages(roomId, localMessages)
|
||||||
|
}
|
||||||
|
return localMessages
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
// ===================
|
// ===================
|
||||||
export interface PreKeyBundle {
|
export interface PreKeyBundle {
|
||||||
identity_key: string
|
identity_key: string
|
||||||
|
identity_key_ed25519: string
|
||||||
signed_prekey: string
|
signed_prekey: string
|
||||||
signed_prekey_signature: string
|
signed_prekey_signature: string
|
||||||
one_time_prekey: string | null
|
one_time_prekey: string | null
|
||||||
|
|
@ -62,6 +63,11 @@ export interface MessageHeader {
|
||||||
previous_chain_length: number
|
previous_chain_length: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface FullMessageHeader {
|
||||||
|
ratchet: MessageHeader
|
||||||
|
x3dh?: X3DHHeader
|
||||||
|
}
|
||||||
|
|
||||||
export interface IdentityKeyPair {
|
export interface IdentityKeyPair {
|
||||||
x25519_private: string
|
x25519_private: string
|
||||||
x25519_public: string
|
x25519_public: string
|
||||||
|
|
|
||||||
|
|
@ -106,7 +106,9 @@ export interface WSOutgoingEncryptedMessage {
|
||||||
type: "encrypted_message"
|
type: "encrypted_message"
|
||||||
recipient_id: string
|
recipient_id: string
|
||||||
room_id: string
|
room_id: string
|
||||||
plaintext: string
|
ciphertext: string
|
||||||
|
nonce: string
|
||||||
|
header: string
|
||||||
temp_id: string
|
temp_id: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -38,16 +38,30 @@ import {
|
||||||
setUserTyping,
|
setUserTyping,
|
||||||
} from "../stores/typing.store"
|
} from "../stores/typing.store"
|
||||||
import { showToast } from "../stores/ui.store"
|
import { showToast } from "../stores/ui.store"
|
||||||
|
import { cryptoService, saveDecryptedMessage, updateMessageId } from "../crypto"
|
||||||
|
|
||||||
type MessageHandler<T extends WSMessage> = (message: T) => void
|
type MessageHandler<T extends WSMessage> = (message: T) => void
|
||||||
|
|
||||||
const encryptedMessageHandler: MessageHandler<EncryptedMessageWS> = (message) => {
|
async function encryptedMessageHandler(message: EncryptedMessageWS): Promise<void> {
|
||||||
|
let decryptedContent: string
|
||||||
|
|
||||||
|
try {
|
||||||
|
decryptedContent = await cryptoService.decrypt(
|
||||||
|
message.sender_id,
|
||||||
|
message.ciphertext,
|
||||||
|
message.nonce,
|
||||||
|
message.header
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
decryptedContent = "[Encrypted message - decryption failed]"
|
||||||
|
}
|
||||||
|
|
||||||
const chatMessage: Message = {
|
const chatMessage: Message = {
|
||||||
id: message.message_id,
|
id: message.message_id,
|
||||||
room_id: message.room_id,
|
room_id: message.room_id,
|
||||||
sender_id: message.sender_id,
|
sender_id: message.sender_id,
|
||||||
sender_username: message.sender_username,
|
sender_username: message.sender_username,
|
||||||
content: message.content,
|
content: decryptedContent,
|
||||||
status: "delivered",
|
status: "delivered",
|
||||||
is_encrypted: true,
|
is_encrypted: true,
|
||||||
encrypted_content: message.ciphertext,
|
encrypted_content: message.ciphertext,
|
||||||
|
|
@ -58,6 +72,7 @@ const encryptedMessageHandler: MessageHandler<EncryptedMessageWS> = (message) =>
|
||||||
}
|
}
|
||||||
|
|
||||||
addMessage(message.room_id, chatMessage)
|
addMessage(message.room_id, chatMessage)
|
||||||
|
void saveDecryptedMessage(chatMessage)
|
||||||
}
|
}
|
||||||
|
|
||||||
const typingIndicatorHandler: MessageHandler<TypingIndicatorWS> = (message) => {
|
const typingIndicatorHandler: MessageHandler<TypingIndicatorWS> = (message) => {
|
||||||
|
|
@ -115,11 +130,12 @@ const messageSentHandler: MessageHandler<MessageSentWS> = (message) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
confirmPendingMessage(message.room_id, message.temp_id, confirmedMessage)
|
confirmPendingMessage(message.room_id, message.temp_id, confirmedMessage)
|
||||||
|
void updateMessageId(message.temp_id, message.message_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function handleWSMessage(message: WSMessage): void {
|
export function handleWSMessage(message: WSMessage): void {
|
||||||
if (isEncryptedMessageWS(message)) {
|
if (isEncryptedMessageWS(message)) {
|
||||||
encryptedMessageHandler(message)
|
void encryptedMessageHandler(message)
|
||||||
} else if (isTypingIndicatorWS(message)) {
|
} else if (isTypingIndicatorWS(message)) {
|
||||||
typingIndicatorHandler(message)
|
typingIndicatorHandler(message)
|
||||||
} else if (isPresenceUpdateWS(message)) {
|
} else if (isPresenceUpdateWS(message)) {
|
||||||
|
|
@ -135,8 +151,8 @@ export function handleWSMessage(message: WSMessage): void {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function handleEncryptedMessage(message: EncryptedMessageWS): void {
|
export async function handleEncryptedMessage(message: EncryptedMessageWS): Promise<void> {
|
||||||
encryptedMessageHandler(message)
|
await encryptedMessageHandler(message)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function handleTypingIndicator(message: TypingIndicatorWS): void {
|
export function handleTypingIndicator(message: TypingIndicatorWS): void {
|
||||||
|
|
|
||||||
|
|
@ -108,14 +108,16 @@ class WebSocketManager {
|
||||||
sendEncryptedMessage(
|
sendEncryptedMessage(
|
||||||
recipientId: string,
|
recipientId: string,
|
||||||
roomId: string,
|
roomId: string,
|
||||||
plaintext: string,
|
encrypted: { ciphertext: string; nonce: string; header: string },
|
||||||
tempId: string
|
tempId: string
|
||||||
): boolean {
|
): boolean {
|
||||||
const message: WSOutgoingEncryptedMessage = {
|
const message: WSOutgoingEncryptedMessage = {
|
||||||
type: "encrypted_message",
|
type: "encrypted_message",
|
||||||
recipient_id: recipientId,
|
recipient_id: recipientId,
|
||||||
room_id: roomId,
|
room_id: roomId,
|
||||||
plaintext,
|
ciphertext: encrypted.ciphertext,
|
||||||
|
nonce: encrypted.nonce,
|
||||||
|
header: encrypted.header,
|
||||||
temp_id: tempId,
|
temp_id: tempId,
|
||||||
}
|
}
|
||||||
return this.send(message)
|
return this.send(message)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,278 @@
|
||||||
|
# =============================================================================
|
||||||
|
# AngelaMos | 2025
|
||||||
|
# Justfile - Encrypted P2P Chat
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
set dotenv-load
|
||||||
|
set export
|
||||||
|
set shell := ["bash", "-uc"]
|
||||||
|
set windows-shell := ["powershell.exe", "-NoLogo", "-Command"]
|
||||||
|
|
||||||
|
project := file_name(justfile_directory())
|
||||||
|
version := `git describe --tags --always 2>/dev/null || echo "dev"`
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Default
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
default:
|
||||||
|
@just --list --unsorted
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Setup
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
[group('setup')]
|
||||||
|
setup: setup-backend setup-frontend env
|
||||||
|
@echo "Setup complete!"
|
||||||
|
|
||||||
|
[group('setup')]
|
||||||
|
setup-backend:
|
||||||
|
@echo "Setting up backend..."
|
||||||
|
cd backend && uv venv ../.venv
|
||||||
|
cd backend && uv pip install -e .[dev]
|
||||||
|
@echo "Backend setup complete!"
|
||||||
|
|
||||||
|
[group('setup')]
|
||||||
|
setup-frontend:
|
||||||
|
@echo "Setting up frontend..."
|
||||||
|
cd frontend && npm install
|
||||||
|
@echo "Frontend setup complete!"
|
||||||
|
|
||||||
|
[group('setup')]
|
||||||
|
env:
|
||||||
|
@echo "Creating .env files..."
|
||||||
|
@if [ ! -f .env ]; then cp .env.example .env; echo "Created root .env"; fi
|
||||||
|
@if [ ! -f frontend/.env ]; then cp frontend/.env.example frontend/.env; echo "Created frontend/.env"; fi
|
||||||
|
@echo ".env files created! Please update with your values."
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Docker Compose (Dev)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
[group('dev')]
|
||||||
|
dev: build-dev up-dev
|
||||||
|
|
||||||
|
[group('dev')]
|
||||||
|
build-dev *ARGS:
|
||||||
|
@echo "Building development Docker images..."
|
||||||
|
docker compose -f dev.compose.yml build {{ARGS}}
|
||||||
|
|
||||||
|
[group('dev')]
|
||||||
|
up-dev *ARGS:
|
||||||
|
@echo "Starting development environment..."
|
||||||
|
docker compose -f dev.compose.yml up {{ARGS}}
|
||||||
|
|
||||||
|
[group('dev')]
|
||||||
|
start-dev *ARGS:
|
||||||
|
@echo "Starting development environment..."
|
||||||
|
docker compose -f dev.compose.yml up -d {{ARGS}}
|
||||||
|
@echo "Development environment started!"
|
||||||
|
@echo "Frontend: http://localhost:5173"
|
||||||
|
@echo "Backend: http://localhost:8000"
|
||||||
|
@echo "Nginx: http://localhost"
|
||||||
|
|
||||||
|
[group('dev')]
|
||||||
|
down-dev *ARGS:
|
||||||
|
@echo "Stopping development environment..."
|
||||||
|
docker compose -f dev.compose.yml down {{ARGS}}
|
||||||
|
|
||||||
|
[group('dev')]
|
||||||
|
stop-dev:
|
||||||
|
docker compose -f dev.compose.yml stop
|
||||||
|
|
||||||
|
[group('dev')]
|
||||||
|
rebuild-dev:
|
||||||
|
docker compose -f dev.compose.yml build --no-cache
|
||||||
|
|
||||||
|
[group('dev')]
|
||||||
|
logs-dev *SERVICE:
|
||||||
|
docker compose -f dev.compose.yml logs -f {{SERVICE}}
|
||||||
|
|
||||||
|
[group('dev')]
|
||||||
|
ps-dev:
|
||||||
|
docker compose -f dev.compose.yml ps
|
||||||
|
|
||||||
|
[group('dev')]
|
||||||
|
shell-dev service='backend':
|
||||||
|
docker compose -f dev.compose.yml exec -it {{service}} /bin/bash
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Docker Compose (Production)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
[group('prod')]
|
||||||
|
prod: build-prod up-prod
|
||||||
|
|
||||||
|
[group('prod')]
|
||||||
|
build-prod *ARGS:
|
||||||
|
@echo "Building production Docker images..."
|
||||||
|
docker compose build {{ARGS}}
|
||||||
|
|
||||||
|
[group('prod')]
|
||||||
|
up-prod *ARGS:
|
||||||
|
@echo "Starting production environment..."
|
||||||
|
docker compose up {{ARGS}}
|
||||||
|
|
||||||
|
[group('prod')]
|
||||||
|
start-prod *ARGS:
|
||||||
|
@echo "Starting production environment..."
|
||||||
|
docker compose up -d {{ARGS}}
|
||||||
|
@echo "Production environment started!"
|
||||||
|
@echo "Application: http://localhost"
|
||||||
|
|
||||||
|
[group('prod')]
|
||||||
|
down-prod *ARGS:
|
||||||
|
@echo "Stopping production environment..."
|
||||||
|
docker compose down {{ARGS}}
|
||||||
|
|
||||||
|
[group('prod')]
|
||||||
|
stop-prod:
|
||||||
|
docker compose stop
|
||||||
|
|
||||||
|
[group('prod')]
|
||||||
|
rebuild-prod:
|
||||||
|
docker compose -f build --no-cache
|
||||||
|
|
||||||
|
[group('prod')]
|
||||||
|
logs-prod *SERVICE:
|
||||||
|
docker compose logs -f {{SERVICE}}
|
||||||
|
|
||||||
|
[group('prod')]
|
||||||
|
ps-prod:
|
||||||
|
docker compose ps
|
||||||
|
|
||||||
|
[group('prod')]
|
||||||
|
shell-prod service='backend':
|
||||||
|
docker compose exec -it {{service}} /bin/bash
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Testing
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
[group('test')]
|
||||||
|
test-backend *ARGS:
|
||||||
|
@echo "Running backend tests..."
|
||||||
|
cd backend && uv run pytest tests/ -v {{ARGS}}
|
||||||
|
|
||||||
|
[group('test')]
|
||||||
|
test: test-backend
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Linting and Formatting
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
[group('lint')]
|
||||||
|
ruff *ARGS:
|
||||||
|
cd backend && uv run ruff check . {{ARGS}}
|
||||||
|
|
||||||
|
[group('lint')]
|
||||||
|
ruff-fix:
|
||||||
|
cd backend && uv run ruff check . --fix
|
||||||
|
cd backend && uv run ruff format .
|
||||||
|
|
||||||
|
[group('lint')]
|
||||||
|
ruff-format:
|
||||||
|
cd backend && uv run ruff format .
|
||||||
|
|
||||||
|
[group('lint')]
|
||||||
|
lint: ruff
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Type Checking
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
[group('types')]
|
||||||
|
mypy *ARGS:
|
||||||
|
cd backend && uv run mypy app {{ARGS}}
|
||||||
|
|
||||||
|
[group('types')]
|
||||||
|
typecheck: mypy
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Database (Docker)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
[group('db')]
|
||||||
|
migrate *ARGS:
|
||||||
|
docker compose -f dev.compose.yml exec backend alembic upgrade {{ARGS}}
|
||||||
|
|
||||||
|
[group('db')]
|
||||||
|
migration message:
|
||||||
|
docker compose -f dev.compose.yml exec backend alembic revision --autogenerate -m "{{message}}"
|
||||||
|
|
||||||
|
[group('db')]
|
||||||
|
rollback:
|
||||||
|
docker compose -f dev.compose.yml exec backend alembic downgrade -1
|
||||||
|
|
||||||
|
[group('db')]
|
||||||
|
db-history:
|
||||||
|
docker compose -f dev.compose.yml exec backend alembic history --verbose
|
||||||
|
|
||||||
|
[group('db')]
|
||||||
|
db-current:
|
||||||
|
docker compose -f dev.compose.yml exec backend alembic current
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Database (Local - no Docker)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
[group('db-local')]
|
||||||
|
migrate-local *ARGS:
|
||||||
|
cd backend && uv run alembic upgrade {{ARGS}}
|
||||||
|
|
||||||
|
[group('db-local')]
|
||||||
|
migration-local message:
|
||||||
|
cd backend && uv run alembic revision --autogenerate -m "{{message}}"
|
||||||
|
|
||||||
|
[group('db-local')]
|
||||||
|
rollback-local:
|
||||||
|
cd backend && uv run alembic downgrade -1
|
||||||
|
|
||||||
|
[group('db-local')]
|
||||||
|
db-history-local:
|
||||||
|
cd backend && uv run alembic history --verbose
|
||||||
|
|
||||||
|
[group('db-local')]
|
||||||
|
db-current-local:
|
||||||
|
cd backend && uv run alembic current
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# CI / Quality
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
[group('ci')]
|
||||||
|
ci: lint typecheck test
|
||||||
|
|
||||||
|
[group('ci')]
|
||||||
|
check: ruff mypy
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Cleanup
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
[group('util')]
|
||||||
|
[confirm("Remove all containers, volumes, and build artifacts?")]
|
||||||
|
clean:
|
||||||
|
@echo "Cleaning up..."
|
||||||
|
-docker compose -f dev.compose.yml down -v
|
||||||
|
-docker compose down -v
|
||||||
|
-rm -rf frontend/node_modules
|
||||||
|
-rm -rf frontend/dist
|
||||||
|
-rm -rf .venv
|
||||||
|
-rm -rf backend/.venv
|
||||||
|
-rm -rf backend/__pycache__
|
||||||
|
-rm -rf backend/.pytest_cache
|
||||||
|
-rm -rf backend/.mypy_cache
|
||||||
|
-rm -rf backend/.ruff_cache
|
||||||
|
@echo "Cleanup complete!"
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Utilities
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
[group('util')]
|
||||||
|
info:
|
||||||
|
@echo "Project: {{project}}"
|
||||||
|
@echo "Version: {{version}}"
|
||||||
|
@echo "OS: {{os()}} ({{arch()}})"
|
||||||
Loading…
Reference in New Issue