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
|
||||
RP_ID=localhost
|
||||
RP_NAME=Encrypted P2P Chat
|
||||
RP_NAME="Encrypted P2P Chat"
|
||||
RP_ORIGIN=http://localhost
|
||||
|
||||
# Frontend (Vite requires VITE_ prefix)
|
||||
|
|
@ -48,7 +48,7 @@ VITE_WS_URL=ws://localhost:8000
|
|||
VITE_RP_ID=localhost
|
||||
|
||||
# CORS
|
||||
CORS_ORIGINS=["http://localhost:3000","http://localhost:5173"]
|
||||
CORS_ORIGINS='["http://localhost:3000","http://localhost:5173"]'
|
||||
|
||||
# WebSocket
|
||||
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
|
||||
- **Node.js 20.19+ or 22.12+** (required for Vite 7)
|
||||
- **Python 3.13+** (latest stable)
|
||||
- **uv** (Python package manager) - `curl -LsSf https://astral.sh/uv/install.sh | sh`
|
||||
- Make
|
||||
|
||||
### Setup
|
||||
|
|
@ -161,10 +162,9 @@ encrypted-p2p-chat/
|
|||
|
||||
```bash
|
||||
cd backend
|
||||
python -m venv ../.venv
|
||||
source ../.venv/bin/activate
|
||||
pip install -e .[dev]
|
||||
python -m pytest tests/ -v
|
||||
uv venv ../.venv
|
||||
uv pip install -e .[dev]
|
||||
uv run pytest tests/ -v
|
||||
```
|
||||
|
||||
### Frontend Development
|
||||
|
|
@ -189,7 +189,7 @@ Or manually:
|
|||
|
||||
```bash
|
||||
cd backend
|
||||
python -m pytest tests/ -v
|
||||
uv run pytest tests/ -v
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
|
|
|||
|
|
@ -4,9 +4,11 @@ Encryption endpoints for X3DH prekey bundles
|
|||
"""
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, status
|
||||
from pydantic import BaseModel
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from app.models.Base import get_session
|
||||
|
|
@ -19,6 +21,14 @@ logger = logging.getLogger(__name__)
|
|||
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(
|
||||
"/prekey-bundle/{user_id}",
|
||||
status_code = status.HTTP_200_OK,
|
||||
|
|
@ -48,6 +58,7 @@ async def initialize_keys(
|
|||
) -> dict[str,
|
||||
str]:
|
||||
"""
|
||||
[DEPRECATED] Server-side key generation - kept for backwards compatibility
|
||||
Initializes encryption keys for a user
|
||||
"""
|
||||
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)
|
||||
async def rotate_signed_prekey(
|
||||
user_id: UUID,
|
||||
|
|
|
|||
|
|
@ -140,8 +140,6 @@ async def list_rooms(
|
|||
"""
|
||||
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))
|
||||
|
||||
if not user:
|
||||
|
|
@ -151,8 +149,6 @@ async def list_rooms(
|
|||
)
|
||||
|
||||
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] = []
|
||||
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ class PreKeyBundle:
|
|||
Recipient prekey bundle for X3DH protocol
|
||||
"""
|
||||
identity_key: str
|
||||
identity_key_ed25519: str
|
||||
signed_prekey: str
|
||||
signed_prekey_signature: str
|
||||
one_time_prekey: str | None = None
|
||||
|
|
|
|||
|
|
@ -44,13 +44,6 @@ class SurrealDBManager:
|
|||
if self._connected:
|
||||
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)
|
||||
await self.db.connect()
|
||||
|
||||
|
|
@ -67,11 +60,6 @@ class SurrealDBManager:
|
|||
)
|
||||
|
||||
self._connected = True
|
||||
logger.warning(
|
||||
"SurrealDB connected successfully to %s/%s",
|
||||
settings.SURREAL_NAMESPACE,
|
||||
settings.SURREAL_DATABASE,
|
||||
)
|
||||
|
||||
async def disconnect(self) -> None:
|
||||
"""
|
||||
|
|
@ -87,31 +75,8 @@ class SurrealDBManager:
|
|||
Ensure connection is established
|
||||
"""
|
||||
if not self._connected:
|
||||
logger.warning("ensure_connected: Connection was lost, reconnecting...")
|
||||
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]]:
|
||||
"""
|
||||
|
|
@ -146,16 +111,13 @@ class SurrealDBManager:
|
|||
|
||||
async def create_message(
|
||||
self,
|
||||
message_data: dict[str,
|
||||
Any]
|
||||
message_data: dict[str, Any]
|
||||
) -> MessageResponse:
|
||||
"""
|
||||
Create a new message in SurrealDB
|
||||
"""
|
||||
await self.ensure_connected()
|
||||
logger.warning("create_message - data: %s", message_data)
|
||||
result = await self.db.create("messages", message_data)
|
||||
logger.warning("create_message - result: %s", result)
|
||||
result["id"] = str(result["id"])
|
||||
return MessageResponse(**result)
|
||||
|
||||
|
|
@ -169,10 +131,6 @@ class SurrealDBManager:
|
|||
Get messages for a specific room with pagination
|
||||
"""
|
||||
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 = """
|
||||
SELECT * FROM messages
|
||||
|
|
@ -189,9 +147,9 @@ class SurrealDBManager:
|
|||
"offset": offset,
|
||||
}
|
||||
)
|
||||
logger.warning("get_room_messages - RAW result: %s", 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]
|
||||
|
||||
async def create_room(self, room_data: dict[str, Any]) -> RoomResponse:
|
||||
|
|
@ -200,15 +158,11 @@ class SurrealDBManager:
|
|||
"""
|
||||
await self.ensure_connected()
|
||||
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})
|
||||
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:
|
||||
first = result[0]
|
||||
logger.info("create_room - first element type: %s, value: %s", type(first), first)
|
||||
|
||||
if isinstance(first, dict):
|
||||
if "result" in first and first["result"]:
|
||||
|
|
@ -216,19 +170,15 @@ class SurrealDBManager:
|
|||
elif "id" in first:
|
||||
room = first
|
||||
else:
|
||||
logger.error("create_room - unexpected dict structure: %s", first)
|
||||
raise ValueError(f"Unexpected result structure: {first}")
|
||||
elif isinstance(first, list) and len(first) > 0:
|
||||
room = first[0]
|
||||
else:
|
||||
logger.error("create_room - unexpected first element: %s", first)
|
||||
raise ValueError(f"Unexpected result: {first}")
|
||||
|
||||
room["id"] = str(room["id"])
|
||||
logger.info("create_room - final room: %s", room)
|
||||
return RoomResponse(**room)
|
||||
|
||||
logger.error("create_room - no results returned: %s", result)
|
||||
raise ValueError(f"Failed to create room, result: {result}")
|
||||
|
||||
async def add_room_participant(
|
||||
|
|
@ -238,10 +188,21 @@ class SurrealDBManager:
|
|||
role: str = "member",
|
||||
) -> None:
|
||||
"""
|
||||
Add a participant to a room
|
||||
Add a participant to a room (skips if already exists)
|
||||
"""
|
||||
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 = """
|
||||
CREATE room_participants CONTENT {
|
||||
room_id: $room_id,
|
||||
|
|
@ -256,16 +217,8 @@ class SurrealDBManager:
|
|||
"role": role,
|
||||
"joined_at": datetime.now(UTC).isoformat(),
|
||||
}
|
||||
logger.warning("add_room_participant - params: %s", params)
|
||||
|
||||
result = 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)
|
||||
await self.db.query(query, params)
|
||||
|
||||
async def get_rooms_for_user(self, user_id: str) -> list[dict[str, Any]]:
|
||||
"""
|
||||
|
|
@ -273,27 +226,18 @@ class SurrealDBManager:
|
|||
"""
|
||||
await self.ensure_connected()
|
||||
|
||||
debug_state = await self._debug_connection_state()
|
||||
logger.warning("get_rooms_for_user - DB STATE: %s", debug_state)
|
||||
|
||||
participants_query = """
|
||||
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})
|
||||
logger.warning("get_rooms_for_user - RAW result: %s", result)
|
||||
participant_data = self._extract_query_result(result)
|
||||
logger.warning("Participant data after extract: %s", participant_data)
|
||||
|
||||
if not participant_data:
|
||||
logger.warning("No room_participants found for user %s", user_id)
|
||||
return []
|
||||
|
||||
room_ids = [p["room_id"] for p in participant_data if p.get("room_id")]
|
||||
logger.info("Room IDs to fetch: %s", room_ids)
|
||||
room_ids = list({p["room_id"] for p in participant_data if p.get("room_id")})
|
||||
|
||||
if not room_ids:
|
||||
logger.warning("No valid room_ids for user %s", user_id)
|
||||
return []
|
||||
|
||||
rooms: list[dict[str, Any]] = []
|
||||
|
|
@ -305,10 +249,9 @@ class SurrealDBManager:
|
|||
rooms.extend(room)
|
||||
else:
|
||||
rooms.append(room)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to fetch room %s: %s", room_id, e)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
logger.info("Fetched %d rooms", len(rooms))
|
||||
return rooms
|
||||
|
||||
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}`"})
|
||||
data = self._extract_query_result(result)
|
||||
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 []
|
||||
|
||||
async def update_presence(
|
||||
|
|
|
|||
|
|
@ -68,6 +68,7 @@ def create_app() -> FastAPI:
|
|||
title = settings.APP_NAME,
|
||||
description = APP_DESCRIPTION,
|
||||
version = APP_VERSION,
|
||||
openapi_version = "3.1.0",
|
||||
docs_url = "/docs" if settings.is_development else None,
|
||||
redoc_url = "/redoc" if settings.is_development else None,
|
||||
default_response_class = ORJSONResponse,
|
||||
|
|
|
|||
|
|
@ -266,6 +266,53 @@ class MessageService:
|
|||
logger.error("Database error saving ratchet state: %s", 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(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
|
|
@ -275,6 +322,7 @@ class MessageService:
|
|||
room_id: str | None = None,
|
||||
) -> Any:
|
||||
"""
|
||||
[DEPRECATED] Server-side encryption - kept for backwards compatibility
|
||||
Encrypts message with Double Ratchet and stores in SurrealDB
|
||||
"""
|
||||
ratchet_state_statement = select(RatchetState).where(
|
||||
|
|
|
|||
|
|
@ -42,6 +42,113 @@ class PrekeyService:
|
|||
"""
|
||||
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(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
|
|
@ -239,6 +346,7 @@ class PrekeyService:
|
|||
|
||||
bundle = PreKeyBundle(
|
||||
identity_key = identity_key.public_key,
|
||||
identity_key_ed25519 = identity_key.public_key_ed25519,
|
||||
signed_prekey = signed_prekey.public_key,
|
||||
signed_prekey_signature = signed_prekey.signature,
|
||||
one_time_prekey = one_time_prekey_public
|
||||
|
|
|
|||
|
|
@ -91,16 +91,18 @@ class WebSocketService:
|
|||
Any]
|
||||
) -> None:
|
||||
"""
|
||||
Process encrypted message from client and forward to recipient
|
||||
Process client-encrypted message and forward to recipient (pass-through)
|
||||
"""
|
||||
try:
|
||||
recipient_id = UUID(message.get("recipient_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", "")
|
||||
|
||||
if not plaintext:
|
||||
logger.error("Missing plaintext in message from %s", user_id)
|
||||
if not ciphertext or not nonce or not header:
|
||||
logger.error("Missing encryption fields in message from %s", user_id)
|
||||
return
|
||||
|
||||
if not room_id:
|
||||
|
|
@ -108,11 +110,13 @@ class WebSocketService:
|
|||
return
|
||||
|
||||
async with async_session_maker() as session:
|
||||
result = await message_service.send_encrypted_message(
|
||||
result = await message_service.store_encrypted_message(
|
||||
session,
|
||||
user_id,
|
||||
recipient_id,
|
||||
plaintext,
|
||||
ciphertext,
|
||||
nonce,
|
||||
header,
|
||||
room_id,
|
||||
)
|
||||
|
||||
|
|
@ -121,15 +125,15 @@ class WebSocketService:
|
|||
sender_id = str(user_id),
|
||||
recipient_id = str(recipient_id),
|
||||
room_id = room_id,
|
||||
content = plaintext,
|
||||
ciphertext = result.ciphertext if hasattr(result, 'ciphertext') else "",
|
||||
nonce = result.nonce if hasattr(result, 'nonce') else "",
|
||||
header = result.header if hasattr(result, 'header') else "",
|
||||
content = "",
|
||||
ciphertext = ciphertext,
|
||||
nonce = nonce,
|
||||
header = header,
|
||||
sender_username = result.sender_username if hasattr(result, 'sender_username') else ""
|
||||
)
|
||||
|
||||
is_recipient_connected = connection_manager.is_user_connected(recipient_id)
|
||||
logger.warning(
|
||||
logger.debug(
|
||||
"Sending to recipient %s - connected: %s",
|
||||
recipient_id,
|
||||
is_recipient_connected
|
||||
|
|
@ -139,7 +143,7 @@ class WebSocketService:
|
|||
recipient_id,
|
||||
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(
|
||||
temp_id = temp_id,
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -5,12 +5,12 @@
|
|||
FROM python:3.13-slim
|
||||
|
||||
ENV PYTHONUNBUFFERED=1 \
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
PIP_NO_CACHE_DIR=1 \
|
||||
PIP_DISABLE_PIP_VERSION_CHECK=1
|
||||
PYTHONDONTWRITEBYTECODE=1
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
|
|
@ -19,7 +19,7 @@ RUN apt-get update && \
|
|||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY backend/pyproject.toml ./
|
||||
RUN pip install -e .[dev]
|
||||
RUN uv pip install --system -e .[dev]
|
||||
|
||||
COPY backend/ .
|
||||
|
||||
|
|
|
|||
|
|
@ -5,12 +5,12 @@
|
|||
FROM python:3.13-slim AS builder
|
||||
|
||||
ENV PYTHONUNBUFFERED=1 \
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
PIP_NO_CACHE_DIR=1 \
|
||||
PIP_DISABLE_PIP_VERSION_CHECK=1
|
||||
PYTHONDONTWRITEBYTECODE=1
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
|
|
@ -18,8 +18,7 @@ RUN apt-get update && \
|
|||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY backend/pyproject.toml ./
|
||||
RUN pip install --user --no-warn-script-location .
|
||||
RUN pip install --user --no-warn-script-location gunicorn
|
||||
RUN uv pip install --target /app/deps . gunicorn
|
||||
|
||||
FROM python:3.13-slim
|
||||
|
||||
|
|
@ -32,8 +31,9 @@ RUN apt-get update && \
|
|||
rm -rf /var/lib/apt/lists/* && \
|
||||
useradd -m -u 1000 appuser
|
||||
|
||||
COPY --from=builder /root/.local /home/appuser/.local
|
||||
ENV PATH=/home/appuser/.local/bin:$PATH
|
||||
COPY --from=builder /app/deps /home/appuser/.local/lib/python3.13/site-packages
|
||||
ENV PYTHONPATH=/home/appuser/.local/lib/python3.13/site-packages:$PYTHONPATH \
|
||||
PATH=/home/appuser/.local/lib/python3.13/site-packages/bin:$PATH
|
||||
|
||||
COPY backend/ .
|
||||
|
||||
|
|
|
|||
|
|
@ -17,23 +17,23 @@ interface MessageBubbleProps {
|
|||
|
||||
export function MessageBubble(props: MessageBubbleProps): JSX.Element {
|
||||
const bubbleClasses = (): string => {
|
||||
const base = "max-w-[80%] p-3"
|
||||
const base = "max-w-[70%] p-4"
|
||||
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 (
|
||||
<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()}>
|
||||
<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}
|
||||
</div>
|
||||
</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}
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
/**
|
||||
* Main application shell with sidebar and content area
|
||||
* Main application shell layout
|
||||
*/
|
||||
|
||||
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 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 (
|
||||
<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()}>
|
||||
<Header />
|
||||
</Show>
|
||||
|
||||
<div class="flex-1 flex overflow-hidden">
|
||||
<Show when={showSidebar()}>
|
||||
<aside
|
||||
class={`
|
||||
flex-shrink-0 h-full
|
||||
border-r-2 border-orange
|
||||
transition-all duration-100
|
||||
${getSidebarClasses()}
|
||||
`}
|
||||
>
|
||||
<div class="flex flex-1 overflow-hidden">
|
||||
<Show when={showSidebar() && sidebarOpen()}>
|
||||
<div class="w-72 h-full border-r-2 border-orange overflow-hidden flex-shrink-0">
|
||||
<Sidebar />
|
||||
</aside>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<main class="flex-1 overflow-hidden bg-black">
|
||||
{props.children}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<Show when={isMobile() && sidebarOpen()}>
|
||||
<div
|
||||
class="fixed inset-0 z-30 bg-black/80"
|
||||
onClick={() => $sidebarOpen.set(false)}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,20 +4,24 @@
|
|||
|
||||
import type { JSX } 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 {
|
||||
$isAuthenticated,
|
||||
$sidebarOpen,
|
||||
$currentUser,
|
||||
toggleSidebar,
|
||||
openModal,
|
||||
} from "../../stores"
|
||||
import { IconButton } from "../UI/IconButton"
|
||||
import { Badge } from "../UI/Badge"
|
||||
import { Avatar } from "../UI/Avatar"
|
||||
|
||||
export function Header(): JSX.Element {
|
||||
const isAuthenticated = useStore($isAuthenticated)
|
||||
const sidebarOpen = useStore($sidebarOpen)
|
||||
const currentUser = useStore($currentUser)
|
||||
const location = useLocation()
|
||||
|
||||
return (
|
||||
<header class="flex-shrink-0 bg-black border-b-4 border-orange">
|
||||
|
|
@ -73,6 +77,21 @@ export function Header(): JSX.Element {
|
|||
size="sm"
|
||||
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>
|
||||
</nav>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,11 +1,10 @@
|
|||
/**
|
||||
* 8-bit styled sidebar component
|
||||
* Sidebar - Room list only
|
||||
*/
|
||||
|
||||
import type { JSX } from "solid-js"
|
||||
import { Show, For } from "solid-js"
|
||||
import type { Room, Participant } from "../../types"
|
||||
import { A, useLocation } from "@solidjs/router"
|
||||
import { Show, Index, createMemo } from "solid-js"
|
||||
import type { Participant } from "../../types"
|
||||
import { useStore } from "@nanostores/solid"
|
||||
import {
|
||||
$currentUser,
|
||||
|
|
@ -24,40 +23,30 @@ export function Sidebar(): JSX.Element {
|
|||
const rooms = useStore($rooms)
|
||||
const activeRoomId = useStore($activeRoomId)
|
||||
const totalUnread = useStore($totalUnreadCount)
|
||||
const location = useLocation()
|
||||
|
||||
const roomList = (): Room[] => {
|
||||
const roomsObj = rooms()
|
||||
console.log("[Sidebar] rooms store:", roomsObj)
|
||||
const roomArray: Room[] = Object.values(roomsObj)
|
||||
console.log("[Sidebar] roomArray length:", roomArray.length)
|
||||
return roomArray.sort((a, b) => {
|
||||
const sortedRooms = createMemo(() => {
|
||||
const arr = Object.values(rooms())
|
||||
return arr.sort((a, b) => {
|
||||
const aTime = a.last_message?.created_at ?? a.updated_at
|
||||
const bTime = b.last_message?.created_at ?? b.updated_at
|
||||
return new Date(bTime).getTime() - new Date(aTime).getTime()
|
||||
})
|
||||
}
|
||||
|
||||
const isActive = (path: string): boolean => location.pathname === path
|
||||
})
|
||||
|
||||
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="flex items-center justify-between">
|
||||
<h2 class="font-pixel text-[10px] text-orange uppercase">
|
||||
Messages
|
||||
</h2>
|
||||
<h2 class="font-pixel text-[10px] text-orange uppercase">Messages</h2>
|
||||
<Show when={totalUnread() > 0}>
|
||||
<Badge variant="primary" size="xs">
|
||||
{totalUnread()}
|
||||
</Badge>
|
||||
<Badge variant="primary" size="xs">{totalUnread()}</Badge>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-2">
|
||||
<IconButton
|
||||
icon={<NewChatIcon />}
|
||||
icon={<PlusIcon />}
|
||||
ariaLabel="New conversation"
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
|
|
@ -66,110 +55,66 @@ export function Sidebar(): JSX.Element {
|
|||
/>
|
||||
</div>
|
||||
|
||||
<nav class="flex-1 overflow-y-auto scrollbar-pixel">
|
||||
<div class="p-2 space-y-1">
|
||||
<For each={roomList()}>
|
||||
{(room) => {
|
||||
const isSelected = (): boolean => activeRoomId() === room.id
|
||||
const otherParticipant = (): Participant | undefined =>
|
||||
room.participants?.find((p: Participant) => p.user_id !== currentUser()?.id)
|
||||
<div class="flex-1 overflow-y-auto p-2">
|
||||
<Index each={sortedRooms()}>
|
||||
{(room, idx) => {
|
||||
const isActive = createMemo(() => activeRoomId() === room().id)
|
||||
const other = createMemo(() => room().participants?.find((p: Participant) => p.user_id !== currentUser()?.id))
|
||||
const displayName = createMemo(() => room().name ?? other()?.display_name ?? "Chat")
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveRoom(room.id)}
|
||||
class={`
|
||||
w-full flex items-center gap-3 p-3
|
||||
border-2 transition-colors duration-100
|
||||
${isSelected()
|
||||
? "bg-orange text-black border-orange"
|
||||
: "bg-black text-white border-transparent hover:border-orange"
|
||||
}
|
||||
`}
|
||||
>
|
||||
<Avatar
|
||||
alt={room.name ?? otherParticipant()?.display_name ?? "Chat"}
|
||||
size="sm"
|
||||
fallback={room.name?.slice(0, 2) ?? otherParticipant()?.display_name?.slice(0, 2)}
|
||||
/>
|
||||
<div class="flex-1 min-w-0 text-left">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="font-pixel text-[10px] truncate">
|
||||
{room.name ?? otherParticipant()?.display_name ?? "Chat"}
|
||||
</span>
|
||||
<Show when={room.unread_count > 0}>
|
||||
<Badge variant="primary" size="xs">
|
||||
{room.unread_count}
|
||||
</Badge>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={room.last_message} keyed>
|
||||
{(lastMsg) => (
|
||||
<p class={`font-pixel text-[8px] truncate mt-0.5 ${
|
||||
isSelected() ? "text-black/70" : "text-gray"
|
||||
}`}>
|
||||
{lastMsg.content}
|
||||
</p>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
<Show when={roomList().length === 0}>
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-room-id={room().id}
|
||||
data-active={isActive()}
|
||||
onClick={() => setActiveRoom(room().id)}
|
||||
style={{
|
||||
width: "100%",
|
||||
display: "flex",
|
||||
"align-items": "center",
|
||||
gap: "12px",
|
||||
padding: "12px",
|
||||
"margin-bottom": "4px",
|
||||
border: isActive() ? "2px solid #FF5300" : "2px solid transparent",
|
||||
background: isActive() ? "#FF5300" : "black",
|
||||
color: isActive() ? "black" : "white",
|
||||
cursor: "pointer",
|
||||
"text-align": "left",
|
||||
}}
|
||||
>
|
||||
<Avatar
|
||||
alt={displayName()}
|
||||
size="sm"
|
||||
fallback={displayName().slice(0, 2)}
|
||||
/>
|
||||
<div style={{ flex: 1, "min-width": 0 }}>
|
||||
<span class="font-pixel text-[10px] truncate block">
|
||||
{displayName()}
|
||||
</span>
|
||||
<Show when={room().last_message}>
|
||||
<p
|
||||
class="font-pixel text-[8px] truncate mt-0.5"
|
||||
style={{ color: isActive() ? "rgba(0,0,0,0.7)" : "#888" }}
|
||||
>
|
||||
{room().last_message?.content}
|
||||
</p>
|
||||
</Show>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}}
|
||||
</Index>
|
||||
<Show when={sortedRooms().length === 0}>
|
||||
<div class="p-4 text-center">
|
||||
<p class="font-pixel text-[8px] text-gray">
|
||||
NO CONVERSATIONS YET
|
||||
</p>
|
||||
<p class="font-pixel text-[8px] text-gray mt-2">
|
||||
START A NEW CHAT TO BEGIN
|
||||
</p>
|
||||
<p class="font-pixel text-[8px] text-gray">NO CONVERSATIONS YET</p>
|
||||
</div>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
function NewChatIcon(): JSX.Element {
|
||||
function PlusIcon(): JSX.Element {
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||
<rect x="7" y="2" width="2" height="12" />
|
||||
|
|
@ -177,16 +122,3 @@ function NewChatIcon(): JSX.Element {
|
|||
</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,
|
||||
DoubleRatchetState,
|
||||
EncryptedMessage,
|
||||
X3DHHeader,
|
||||
FullMessageHeader,
|
||||
MessageHeader,
|
||||
} from "../types"
|
||||
import { DEFAULT_ONE_TIME_PREKEY_COUNT } from "../types"
|
||||
import {
|
||||
|
|
@ -32,7 +35,7 @@ import {
|
|||
getLatestSignedPreKey,
|
||||
saveOneTimePreKeys,
|
||||
getUnusedOneTimePreKeys,
|
||||
getOneTimePreKey,
|
||||
getOneTimePreKeyByPublicKey,
|
||||
markOneTimePreKeyUsed,
|
||||
saveRatchetState,
|
||||
getRatchetState,
|
||||
|
|
@ -43,7 +46,8 @@ import { api } from "../lib/api-client"
|
|||
import {
|
||||
base64ToBytes,
|
||||
bytesToBase64,
|
||||
generateX25519KeyPair,
|
||||
importX25519PrivateKey,
|
||||
importX25519PublicKey,
|
||||
} from "./primitives"
|
||||
|
||||
class CryptoService {
|
||||
|
|
@ -51,6 +55,7 @@ class CryptoService {
|
|||
private identityKeyPair: IdentityKeyPair | null = null
|
||||
private signedPreKey: SignedPreKey | null = null
|
||||
private ratchetStates = new Map<string, DoubleRatchetState>()
|
||||
private pendingX3DHHeaders = new Map<string, X3DHHeader>()
|
||||
private initialized = false
|
||||
|
||||
async initialize(userId: string): Promise<void> {
|
||||
|
|
@ -114,9 +119,19 @@ class CryptoService {
|
|||
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")
|
||||
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> {
|
||||
|
|
@ -129,16 +144,23 @@ class CryptoService {
|
|||
|
||||
const x3dhResult = await initiateX3DH(this.identityKeyPair, peerBundle)
|
||||
|
||||
const peerIdentityKey = base64ToBytes(peerBundle.identity_key)
|
||||
const peerSignedPrekey = base64ToBytes(peerBundle.signed_prekey)
|
||||
|
||||
const ratchetState = await initializeRatchetSender(
|
||||
peerId,
|
||||
x3dhResult.shared_key,
|
||||
peerIdentityKey
|
||||
peerSignedPrekey
|
||||
)
|
||||
|
||||
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)
|
||||
await saveRatchetState(serialized)
|
||||
}
|
||||
|
|
@ -147,17 +169,17 @@ class CryptoService {
|
|||
peerId: string,
|
||||
senderIdentityKey: string,
|
||||
ephemeralKey: string,
|
||||
oneTimePreKeyId: string | null
|
||||
oneTimePreKeyPublic: string | null
|
||||
): Promise<void> {
|
||||
if (this.identityKeyPair === null || this.signedPreKey === null) {
|
||||
throw new Error("Keys not initialized")
|
||||
}
|
||||
|
||||
let oneTimePreKey: OneTimePreKey | null = null
|
||||
if (oneTimePreKeyId !== null) {
|
||||
oneTimePreKey = await getOneTimePreKey(oneTimePreKeyId)
|
||||
if (oneTimePreKeyPublic !== null) {
|
||||
oneTimePreKey = await getOneTimePreKeyByPublicKey(oneTimePreKeyPublic)
|
||||
if (oneTimePreKey !== null) {
|
||||
await markOneTimePreKeyUsed(oneTimePreKeyId)
|
||||
await markOneTimePreKeyUsed(oneTimePreKey.id)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -169,12 +191,21 @@ class CryptoService {
|
|||
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(
|
||||
peerId,
|
||||
sharedKey,
|
||||
dhKeyPair
|
||||
signedPreKeyPair
|
||||
)
|
||||
|
||||
this.ratchetStates.set(peerId, ratchetState)
|
||||
|
|
@ -200,10 +231,20 @@ class CryptoService {
|
|||
const serialized = await serializeRatchetState(state)
|
||||
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 {
|
||||
ciphertext: bytesToBase64(encrypted.ciphertext),
|
||||
nonce: bytesToBase64(encrypted.nonce),
|
||||
header: JSON.stringify(encrypted.header),
|
||||
header: JSON.stringify(fullHeader),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -215,19 +256,34 @@ class CryptoService {
|
|||
): Promise<string> {
|
||||
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 = {
|
||||
ciphertext: base64ToBytes(ciphertext),
|
||||
nonce: base64ToBytes(nonce),
|
||||
header: parsedHeader,
|
||||
header: ratchetHeader,
|
||||
}
|
||||
|
||||
if (state === null) {
|
||||
if (!x3dhHeader) {
|
||||
throw new Error("Cannot establish session: missing X3DH header")
|
||||
}
|
||||
|
||||
await this.handleIncomingSession(
|
||||
peerId,
|
||||
parsedHeader.dh_public_key,
|
||||
parsedHeader.dh_public_key,
|
||||
null
|
||||
x3dhHeader.identity_key,
|
||||
x3dhHeader.ephemeral_key,
|
||||
x3dhHeader.one_time_prekey_id
|
||||
)
|
||||
state = await this.getRatchetState(peerId)
|
||||
}
|
||||
|
|
@ -276,6 +332,23 @@ class CryptoService {
|
|||
isInitialized(): boolean {
|
||||
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()
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
(window as unknown as { resetCryptoSessions: () => Promise<void> }).resetCryptoSessions =
|
||||
() => cryptoService.resetAllSessions()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
// ===================
|
||||
export * from "./primitives"
|
||||
export * from "./key-store"
|
||||
export * from "./message-store"
|
||||
export * from "./x3dh"
|
||||
export * from "./double-ratchet"
|
||||
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[]> {
|
||||
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> {
|
||||
if (keyBytes.length === 32) {
|
||||
return await subtle.importKey(
|
||||
"raw",
|
||||
keyBytes.buffer as ArrayBuffer,
|
||||
{ name: "X25519" },
|
||||
true,
|
||||
[]
|
||||
)
|
||||
}
|
||||
return await subtle.importKey(
|
||||
"raw",
|
||||
"spki",
|
||||
keyBytes.buffer as ArrayBuffer,
|
||||
{
|
||||
name: "X25519",
|
||||
},
|
||||
{ name: "X25519" },
|
||||
true,
|
||||
[]
|
||||
)
|
||||
}
|
||||
|
||||
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(
|
||||
"raw",
|
||||
"pkcs8",
|
||||
keyBytes.buffer as ArrayBuffer,
|
||||
{
|
||||
name: "X25519",
|
||||
},
|
||||
{ name: "X25519" },
|
||||
true,
|
||||
["deriveBits"]
|
||||
)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
|
|
@ -112,24 +126,38 @@ export async function ed25519Verify(
|
|||
}
|
||||
|
||||
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(
|
||||
"raw",
|
||||
"spki",
|
||||
keyBytes.buffer as ArrayBuffer,
|
||||
{
|
||||
name: "Ed25519",
|
||||
},
|
||||
{ name: "Ed25519" },
|
||||
true,
|
||||
["verify"]
|
||||
)
|
||||
}
|
||||
|
||||
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(
|
||||
"raw",
|
||||
"pkcs8",
|
||||
keyBytes.buffer as ArrayBuffer,
|
||||
{
|
||||
name: "Ed25519",
|
||||
},
|
||||
{ name: "Ed25519" },
|
||||
true,
|
||||
["sign"]
|
||||
)
|
||||
|
|
|
|||
|
|
@ -117,7 +117,7 @@ export async function initiateX3DH(
|
|||
recipientBundle: PreKeyBundle
|
||||
): Promise<X3DHResult> {
|
||||
const signatureValid = await verifySignedPreKey(
|
||||
recipientBundle.identity_key,
|
||||
recipientBundle.identity_key_ed25519,
|
||||
recipientBundle.signed_prekey,
|
||||
recipientBundle.signed_prekey_signature
|
||||
)
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ import {
|
|||
wsManager,
|
||||
} from "../websocket"
|
||||
import { roomService } from "../services"
|
||||
import { cryptoService, saveDecryptedMessage } from "../crypto"
|
||||
|
||||
export default function Chat(): JSX.Element {
|
||||
const activeRoom = useStore($activeRoom)
|
||||
|
|
@ -47,14 +48,13 @@ export default function Chat(): JSX.Element {
|
|||
|
||||
onMount(async () => {
|
||||
const currentUserId = userId()
|
||||
console.log("[Chat] onMount - userId:", currentUserId)
|
||||
if (currentUserId) {
|
||||
console.log("[Chat] Connecting WebSocket and loading rooms...")
|
||||
try {
|
||||
await cryptoService.initialize(currentUserId)
|
||||
} catch {
|
||||
}
|
||||
connectWebSocket()
|
||||
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()
|
||||
})
|
||||
|
||||
const handleSendMessage = (content: string): void => {
|
||||
const handleSendMessage = async (content: string): Promise<void> => {
|
||||
const roomId = activeRoomId()
|
||||
const room = activeRoom()
|
||||
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 now = new Date().toISOString()
|
||||
|
||||
addMessage(roomId, {
|
||||
const messageToSend = {
|
||||
id: tempId,
|
||||
room_id: roomId,
|
||||
sender_id: currentUserId,
|
||||
sender_username: user?.username ?? "me",
|
||||
content,
|
||||
status: "sending",
|
||||
is_encrypted: false,
|
||||
status: "sending" as const,
|
||||
is_encrypted: true,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
})
|
||||
}
|
||||
|
||||
const sent = wsManager.sendEncryptedMessage(
|
||||
recipientId,
|
||||
roomId,
|
||||
content,
|
||||
tempId
|
||||
)
|
||||
addMessage(roomId, messageToSend)
|
||||
|
||||
if (!sent) {
|
||||
showToast("error", "SEND FAILED", "NOT CONNECTED")
|
||||
try {
|
||||
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"
|
||||
import { isPublicKeyCredential } from "../types/guards"
|
||||
import { setCurrentUser, logout as storeLogout } from "../stores"
|
||||
import { cryptoService } from "../crypto"
|
||||
|
||||
interface PublicKeyCredentialStatic {
|
||||
isUserVerifyingPlatformAuthenticatorAvailable(): Promise<boolean>
|
||||
|
|
@ -131,6 +132,7 @@ export async function register(
|
|||
})
|
||||
|
||||
setCurrentUser(user)
|
||||
await cryptoService.initialize(user.id)
|
||||
|
||||
return user
|
||||
}
|
||||
|
|
@ -161,6 +163,7 @@ export async function login(username?: string): Promise<User> {
|
|||
})
|
||||
|
||||
setCurrentUser(user)
|
||||
await cryptoService.initialize(user.id)
|
||||
|
||||
return user
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,18 +10,21 @@ import {
|
|||
addRoom,
|
||||
setRoomMessages,
|
||||
setHasMore,
|
||||
$userId,
|
||||
} from "../stores"
|
||||
import {
|
||||
getDecryptedMessages,
|
||||
getDecryptedMessage,
|
||||
saveDecryptedMessage,
|
||||
cryptoService,
|
||||
} from "../crypto"
|
||||
|
||||
export async function loadRooms(userId: string): Promise<Room[]> {
|
||||
console.log("[RoomService] loadRooms called with userId:", userId)
|
||||
try {
|
||||
const response = await api.rooms.list(userId)
|
||||
console.log("[RoomService] API response:", response)
|
||||
console.log("[RoomService] rooms count:", response.rooms.length)
|
||||
setRooms(response.rooms)
|
||||
return response.rooms
|
||||
} catch (err) {
|
||||
console.error("[RoomService] Failed to load rooms:", err)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
|
@ -51,28 +54,85 @@ export async function loadMessages(
|
|||
offset: number = 0
|
||||
): Promise<Message[]> {
|
||||
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 messages: Message[] = response.messages.map((msg) => ({
|
||||
id: msg.id,
|
||||
room_id: msg.room_id,
|
||||
sender_id: msg.sender_id,
|
||||
sender_username: msg.sender_username,
|
||||
content: "[Encrypted message]",
|
||||
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,
|
||||
}))
|
||||
const reversedMessages = messages.reverse()
|
||||
setRoomMessages(roomId, reversedMessages)
|
||||
const serverMessages = response.messages.reverse()
|
||||
|
||||
const newMessages: Message[] = []
|
||||
|
||||
const currentUserId = $userId.get()
|
||||
|
||||
for (const msg of serverMessages) {
|
||||
if (localMessageIds.has(msg.id)) {
|
||||
continue
|
||||
}
|
||||
|
||||
let content = "[Encrypted - from another session]"
|
||||
const isOwnMessage = msg.sender_id === currentUserId
|
||||
|
||||
if (isOwnMessage) {
|
||||
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)
|
||||
return reversedMessages
|
||||
return uniqueMessages
|
||||
} catch (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 {
|
||||
identity_key: string
|
||||
identity_key_ed25519: string
|
||||
signed_prekey: string
|
||||
signed_prekey_signature: string
|
||||
one_time_prekey: string | null
|
||||
|
|
@ -62,6 +63,11 @@ export interface MessageHeader {
|
|||
previous_chain_length: number
|
||||
}
|
||||
|
||||
export interface FullMessageHeader {
|
||||
ratchet: MessageHeader
|
||||
x3dh?: X3DHHeader
|
||||
}
|
||||
|
||||
export interface IdentityKeyPair {
|
||||
x25519_private: string
|
||||
x25519_public: string
|
||||
|
|
|
|||
|
|
@ -106,7 +106,9 @@ export interface WSOutgoingEncryptedMessage {
|
|||
type: "encrypted_message"
|
||||
recipient_id: string
|
||||
room_id: string
|
||||
plaintext: string
|
||||
ciphertext: string
|
||||
nonce: string
|
||||
header: string
|
||||
temp_id: string
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -38,16 +38,30 @@ import {
|
|||
setUserTyping,
|
||||
} from "../stores/typing.store"
|
||||
import { showToast } from "../stores/ui.store"
|
||||
import { cryptoService, saveDecryptedMessage, updateMessageId } from "../crypto"
|
||||
|
||||
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 = {
|
||||
id: message.message_id,
|
||||
room_id: message.room_id,
|
||||
sender_id: message.sender_id,
|
||||
sender_username: message.sender_username,
|
||||
content: message.content,
|
||||
content: decryptedContent,
|
||||
status: "delivered",
|
||||
is_encrypted: true,
|
||||
encrypted_content: message.ciphertext,
|
||||
|
|
@ -58,6 +72,7 @@ const encryptedMessageHandler: MessageHandler<EncryptedMessageWS> = (message) =>
|
|||
}
|
||||
|
||||
addMessage(message.room_id, chatMessage)
|
||||
void saveDecryptedMessage(chatMessage)
|
||||
}
|
||||
|
||||
const typingIndicatorHandler: MessageHandler<TypingIndicatorWS> = (message) => {
|
||||
|
|
@ -115,11 +130,12 @@ const messageSentHandler: MessageHandler<MessageSentWS> = (message) => {
|
|||
}
|
||||
|
||||
confirmPendingMessage(message.room_id, message.temp_id, confirmedMessage)
|
||||
void updateMessageId(message.temp_id, message.message_id)
|
||||
}
|
||||
|
||||
export function handleWSMessage(message: WSMessage): void {
|
||||
if (isEncryptedMessageWS(message)) {
|
||||
encryptedMessageHandler(message)
|
||||
void encryptedMessageHandler(message)
|
||||
} else if (isTypingIndicatorWS(message)) {
|
||||
typingIndicatorHandler(message)
|
||||
} else if (isPresenceUpdateWS(message)) {
|
||||
|
|
@ -135,8 +151,8 @@ export function handleWSMessage(message: WSMessage): void {
|
|||
}
|
||||
}
|
||||
|
||||
export function handleEncryptedMessage(message: EncryptedMessageWS): void {
|
||||
encryptedMessageHandler(message)
|
||||
export async function handleEncryptedMessage(message: EncryptedMessageWS): Promise<void> {
|
||||
await encryptedMessageHandler(message)
|
||||
}
|
||||
|
||||
export function handleTypingIndicator(message: TypingIndicatorWS): void {
|
||||
|
|
|
|||
|
|
@ -108,14 +108,16 @@ class WebSocketManager {
|
|||
sendEncryptedMessage(
|
||||
recipientId: string,
|
||||
roomId: string,
|
||||
plaintext: string,
|
||||
encrypted: { ciphertext: string; nonce: string; header: string },
|
||||
tempId: string
|
||||
): boolean {
|
||||
const message: WSOutgoingEncryptedMessage = {
|
||||
type: "encrypted_message",
|
||||
recipient_id: recipientId,
|
||||
room_id: roomId,
|
||||
plaintext,
|
||||
ciphertext: encrypted.ciphertext,
|
||||
nonce: encrypted.nonce,
|
||||
header: encrypted.header,
|
||||
temp_id: tempId,
|
||||
}
|
||||
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