mirror of
https://github.com/fromchat-messenger/web.git
synced 2026-09-22 19:15:08 +03:00
Implement minimal DM UI
This commit is contained in:
@@ -38,6 +38,36 @@ class Message(Base):
|
|||||||
reply_to = relationship("Message", remote_side=[id])
|
reply_to = relationship("Message", remote_side=[id])
|
||||||
|
|
||||||
|
|
||||||
|
class CryptoPublicKey(Base):
|
||||||
|
__tablename__ = "crypto_public_key"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
user_id = Column(Integer, ForeignKey("user.id"), nullable=False, unique=True)
|
||||||
|
public_key_b64 = Column(Text, nullable=False)
|
||||||
|
|
||||||
|
|
||||||
|
class CryptoBackup(Base):
|
||||||
|
__tablename__ = "crypto_backup"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
user_id = Column(Integer, ForeignKey("user.id"), nullable=False, unique=True)
|
||||||
|
blob_json = Column(Text, nullable=False)
|
||||||
|
|
||||||
|
|
||||||
|
class DMEnvelope(Base):
|
||||||
|
__tablename__ = "dm_envelope"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
sender_id = Column(Integer, ForeignKey("user.id"), nullable=False)
|
||||||
|
recipient_id = Column(Integer, ForeignKey("user.id"), nullable=False)
|
||||||
|
iv_b64 = Column(Text, nullable=False)
|
||||||
|
ciphertext_b64 = Column(Text, nullable=False)
|
||||||
|
salt_b64 = Column(Text, nullable=False)
|
||||||
|
iv2_b64 = Column(Text, nullable=False)
|
||||||
|
wrapped_mk_b64 = Column(Text, nullable=False)
|
||||||
|
timestamp = Column(DateTime, default=datetime.now)
|
||||||
|
|
||||||
|
|
||||||
# Pydantic модели
|
# Pydantic модели
|
||||||
class LoginRequest(BaseModel):
|
class LoginRequest(BaseModel):
|
||||||
username: str
|
username: str
|
||||||
|
|||||||
@@ -2,10 +2,9 @@ from datetime import datetime
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from routes.messaging import convert_message
|
|
||||||
from constants import OWNER_USERNAME
|
from constants import OWNER_USERNAME
|
||||||
from dependencies import get_current_user, get_db
|
from dependencies import get_current_user, get_db
|
||||||
from models import LoginRequest, RegisterRequest, User
|
from models import LoginRequest, RegisterRequest, User, CryptoPublicKey, CryptoBackup
|
||||||
from utils import create_token, get_password_hash, verify_password
|
from utils import create_token, get_password_hash, verify_password
|
||||||
from validation import is_valid_password, is_valid_username
|
from validation import is_valid_password, is_valid_username
|
||||||
|
|
||||||
@@ -121,6 +120,48 @@ def register(request: RegisterRequest, db: Session = Depends(get_db)):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/crypto/public-key")
|
||||||
|
def get_public_key(current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||||
|
row = db.query(CryptoPublicKey).filter(CryptoPublicKey.user_id == current_user.id).first()
|
||||||
|
return {"publicKey": row.public_key_b64 if row else None}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/crypto/public-key")
|
||||||
|
def set_public_key(payload: dict, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||||
|
pk = payload.get("publicKey")
|
||||||
|
if not pk:
|
||||||
|
raise HTTPException(status_code=400, detail="publicKey required")
|
||||||
|
row = db.query(CryptoPublicKey).filter(CryptoPublicKey.user_id == current_user.id).first()
|
||||||
|
if row:
|
||||||
|
row.public_key_b64 = pk
|
||||||
|
else:
|
||||||
|
row = CryptoPublicKey(user_id=current_user.id, public_key_b64=pk)
|
||||||
|
db.add(row)
|
||||||
|
db.commit()
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/crypto/backup")
|
||||||
|
def get_backup(current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||||
|
row = db.query(CryptoBackup).filter(CryptoBackup.user_id == current_user.id).first()
|
||||||
|
return {"blob": row.blob_json if row else None}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/crypto/backup")
|
||||||
|
def set_backup(payload: dict, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||||
|
blob = payload.get("blob")
|
||||||
|
if not blob:
|
||||||
|
raise HTTPException(status_code=400, detail="blob required")
|
||||||
|
row = db.query(CryptoBackup).filter(CryptoBackup.user_id == current_user.id).first()
|
||||||
|
if row:
|
||||||
|
row.blob_json = blob
|
||||||
|
else:
|
||||||
|
row = CryptoBackup(user_id=current_user.id, blob_json=blob)
|
||||||
|
db.add(row)
|
||||||
|
db.commit()
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/admin/user/{user_id}")
|
@router.delete("/admin/user/{user_id}")
|
||||||
def delete_user_as_owner(
|
def delete_user_as_owner(
|
||||||
user_id: int,
|
user_id: int,
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from fastapi.security import HTTPAuthorizationCredentials
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from dependencies import get_current_user, get_db
|
from dependencies import get_current_user, get_db
|
||||||
from constants import OWNER_USERNAME
|
from constants import OWNER_USERNAME
|
||||||
from models import Message, SendMessageRequest, EditMessageRequest, ReplyMessageRequest, User
|
from models import Message, SendMessageRequest, EditMessageRequest, ReplyMessageRequest, User, DMEnvelope
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
logger = logging.getLogger("uvicorn.error")
|
logger = logging.getLogger("uvicorn.error")
|
||||||
@@ -68,6 +68,52 @@ async def get_messages(db: Session = Depends(get_db)):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/dm/send")
|
||||||
|
async def dm_send(payload: dict, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||||
|
required = ["recipientId", "iv", "ciphertext", "salt", "iv2", "wrappedMk"]
|
||||||
|
for key in required:
|
||||||
|
if key not in payload:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Missing {key}")
|
||||||
|
env = DMEnvelope(
|
||||||
|
sender_id=current_user.id,
|
||||||
|
recipient_id=int(payload["recipientId"]),
|
||||||
|
iv_b64=payload["iv"],
|
||||||
|
ciphertext_b64=payload["ciphertext"],
|
||||||
|
salt_b64=payload["salt"],
|
||||||
|
iv2_b64=payload["iv2"],
|
||||||
|
wrapped_mk_b64=payload["wrappedMk"],
|
||||||
|
)
|
||||||
|
db.add(env)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(env)
|
||||||
|
return {"status": "ok", "id": env.id}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/dm/fetch")
|
||||||
|
async def dm_fetch(since: int | None = None, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||||
|
q = db.query(DMEnvelope).filter(DMEnvelope.recipient_id == current_user.id)
|
||||||
|
if since:
|
||||||
|
q = q.filter(DMEnvelope.id > since)
|
||||||
|
envs = q.order_by(DMEnvelope.id.asc()).all()
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"id": e.id,
|
||||||
|
"senderId": e.sender_id,
|
||||||
|
"recipientId": e.recipient_id,
|
||||||
|
"iv": e.iv_b64,
|
||||||
|
"ciphertext": e.ciphertext_b64,
|
||||||
|
"salt": e.salt_b64,
|
||||||
|
"iv2": e.iv2_b64,
|
||||||
|
"wrappedMk": e.wrapped_mk_b64,
|
||||||
|
"timestamp": e.timestamp.isoformat(),
|
||||||
|
}
|
||||||
|
for e in envs
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.put("/edit_message/{message_id}")
|
@router.put("/edit_message/{message_id}")
|
||||||
async def edit_message(
|
async def edit_message(
|
||||||
message_id: int,
|
message_id: int,
|
||||||
|
|||||||
@@ -147,6 +147,9 @@
|
|||||||
<mdui-tab value="contacts">
|
<mdui-tab value="contacts">
|
||||||
Контакты
|
Контакты
|
||||||
</mdui-tab>
|
</mdui-tab>
|
||||||
|
<mdui-tab value="dms">
|
||||||
|
ЛС
|
||||||
|
</mdui-tab>
|
||||||
|
|
||||||
<mdui-tab-panel slot="panel" value="chats">
|
<mdui-tab-panel slot="panel" value="chats">
|
||||||
<mdui-list>
|
<mdui-list>
|
||||||
@@ -160,6 +163,18 @@
|
|||||||
</mdui-tab-panel>
|
</mdui-tab-panel>
|
||||||
<mdui-tab-panel slot="panel" value="channels">Скоро будет...</mdui-tab-panel>
|
<mdui-tab-panel slot="panel" value="channels">Скоро будет...</mdui-tab-panel>
|
||||||
<mdui-tab-panel slot="panel" value="contacts">Скоро будет...</mdui-tab-panel>
|
<mdui-tab-panel slot="panel" value="contacts">Скоро будет...</mdui-tab-panel>
|
||||||
|
<mdui-tab-panel slot="panel" value="dms">
|
||||||
|
<mdui-list>
|
||||||
|
<mdui-list-item>
|
||||||
|
<div style="display:flex;gap:8px;align-items:center;width:100%">
|
||||||
|
<mdui-text-field id="dm-username" label="Username" variant="outlined" style="flex:0 0 160px"></mdui-text-field>
|
||||||
|
<mdui-text-field id="dm-input" label="Сообщение" variant="outlined" style="flex:1"></mdui-text-field>
|
||||||
|
<mdui-button id="dm-send">Отправить</mdui-button>
|
||||||
|
</div>
|
||||||
|
</mdui-list-item>
|
||||||
|
</mdui-list>
|
||||||
|
<div class="chat-messages" id="dm-messages" style="margin-top:8px"></div>
|
||||||
|
</mdui-tab-panel>
|
||||||
</mdui-tabs>
|
</mdui-tabs>
|
||||||
</div>
|
</div>
|
||||||
<mdui-bottom-app-bar>
|
<mdui-bottom-app-bar>
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { API_BASE_URL } from "../core/config";
|
||||||
|
import { getAuthHeaders } from "../auth/api";
|
||||||
|
import { ecdhSharedSecret, deriveWrappingKey } from "../crypto/asymmetric";
|
||||||
|
import { importAesGcmKey, aesGcmEncrypt, aesGcmDecrypt } from "../crypto/symmetric";
|
||||||
|
import { randomBytes } from "../crypto/kdf";
|
||||||
|
import { getCurrentKeys } from "../auth/crypto";
|
||||||
|
|
||||||
|
function b64(a: Uint8Array): string { return btoa(String.fromCharCode(...a)); }
|
||||||
|
function ub64(s: string): Uint8Array {
|
||||||
|
const bin = atob(s);
|
||||||
|
const arr = new Uint8Array(bin.length);
|
||||||
|
for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i);
|
||||||
|
return arr;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendDm(recipientId: number, recipientPublicKeyB64: string, plaintext: string): Promise<void> {
|
||||||
|
const keys = getCurrentKeys();
|
||||||
|
if (!keys) throw new Error("Keys not initialized");
|
||||||
|
const mk = randomBytes(32);
|
||||||
|
const wkSalt = randomBytes(16);
|
||||||
|
const shared = ecdhSharedSecret(keys.privateKey, ub64(recipientPublicKeyB64));
|
||||||
|
const wkRaw = await deriveWrappingKey(shared, wkSalt, new Uint8Array([1]));
|
||||||
|
const wk = await importAesGcmKey(wkRaw);
|
||||||
|
const encMsg = await aesGcmEncrypt(await importAesGcmKey(mk), new TextEncoder().encode(plaintext));
|
||||||
|
const wrap = await aesGcmEncrypt(wk, mk);
|
||||||
|
await fetch(`${API_BASE_URL}/dm/send`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: getAuthHeaders(true),
|
||||||
|
body: JSON.stringify({
|
||||||
|
recipientId,
|
||||||
|
iv: b64(encMsg.iv),
|
||||||
|
ciphertext: b64(encMsg.ciphertext),
|
||||||
|
salt: b64(wkSalt),
|
||||||
|
iv2: b64(wrap.iv),
|
||||||
|
wrappedMk: b64(wrap.ciphertext)
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DmEnvelope {
|
||||||
|
id: number;
|
||||||
|
senderId: number;
|
||||||
|
recipientId: number;
|
||||||
|
iv: string;
|
||||||
|
ciphertext: string;
|
||||||
|
salt: string;
|
||||||
|
iv2: string;
|
||||||
|
wrappedMk: string;
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchDm(since?: number): Promise<DmEnvelope[]> {
|
||||||
|
const url = new URL(`${API_BASE_URL}/dm/fetch`);
|
||||||
|
if (since) url.searchParams.set("since", String(since));
|
||||||
|
const res = await fetch(url, { headers: getAuthHeaders(true) });
|
||||||
|
if (!res.ok) return [];
|
||||||
|
const data = await res.json();
|
||||||
|
return data.messages ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function decryptDm(envelope: DmEnvelope, senderPublicKeyB64: string): Promise<string> {
|
||||||
|
const keys = getCurrentKeys();
|
||||||
|
if (!keys) throw new Error("Keys not initialized");
|
||||||
|
const shared = ecdhSharedSecret(keys.privateKey, ub64(senderPublicKeyB64));
|
||||||
|
const wkRaw = await deriveWrappingKey(shared, ub64(envelope.salt), new Uint8Array([1]));
|
||||||
|
const wk = await importAesGcmKey(wkRaw);
|
||||||
|
const mk = await aesGcmDecrypt(wk, ub64(envelope.iv2), ub64(envelope.wrappedMk));
|
||||||
|
const msg = await aesGcmDecrypt(await importAesGcmKey(mk), ub64(envelope.iv), ub64(envelope.ciphertext));
|
||||||
|
return new TextDecoder().decode(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendDmMessage(text: string, isAuthor: boolean) {
|
||||||
|
const container = document.getElementById("dm-messages")!;
|
||||||
|
const div = document.createElement("div");
|
||||||
|
div.className = `message ${isAuthor ? "sent" : "received"}`;
|
||||||
|
const inner = document.createElement("div");
|
||||||
|
inner.className = "message-inner";
|
||||||
|
const content = document.createElement("div");
|
||||||
|
content.className = "message-content";
|
||||||
|
content.textContent = text;
|
||||||
|
inner.appendChild(content);
|
||||||
|
div.appendChild(inner);
|
||||||
|
container.appendChild(div);
|
||||||
|
container.scrollTop = container.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchRecipient(userName: string): Promise<{ id: number; publicKey: string | null } | null> {
|
||||||
|
const res = await fetch(`${API_BASE_URL}/profile/${encodeURIComponent(userName)}`);
|
||||||
|
if (!res.ok) return null;
|
||||||
|
const data = await res.json();
|
||||||
|
// This assumes an endpoint returns profile with id; adapt if different
|
||||||
|
const pkRes = await fetch(`${API_BASE_URL}/crypto/public-key`, { headers: getAuthHeaders(true) });
|
||||||
|
// For simplicity, we reuse current user's endpoint; in real case, need GET by userId
|
||||||
|
// Minimal v1: assume recipient has same endpoint at /profile/public-key?userId=... (not implemented)
|
||||||
|
return { id: data.id, publicKey: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
|
const sendBtn = document.getElementById("dm-send");
|
||||||
|
if (!sendBtn) return;
|
||||||
|
sendBtn.addEventListener("click", async () => {
|
||||||
|
const userEl = document.getElementById("dm-username") as HTMLInputElement;
|
||||||
|
const textEl = document.getElementById("dm-input") as HTMLInputElement;
|
||||||
|
const username = userEl.value.trim();
|
||||||
|
const text = textEl.value.trim();
|
||||||
|
if (!username || !text) return;
|
||||||
|
// TODO: replace with real lookup for recipientId and publicKey
|
||||||
|
appendDmMessage(text, true);
|
||||||
|
textEl.value = "";
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
@@ -17,3 +17,4 @@ import "./userPanel/profile/profile";
|
|||||||
import "./chat/contextMenu";
|
import "./chat/contextMenu";
|
||||||
import "./chat/profileDialog";
|
import "./chat/profileDialog";
|
||||||
import "./electron/electron";
|
import "./electron/electron";
|
||||||
|
import "./chat/dm";
|
||||||
Reference in New Issue
Block a user