First commit in rust
This commit is contained in:
236
backchannel-web/src/App.tsx
Normal file
236
backchannel-web/src/App.tsx
Normal file
@@ -0,0 +1,236 @@
|
||||
import { FormEvent, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { getOrCreateIdentityKey } from "./crypto";
|
||||
import { ClientMessage, GENERAL_CHANNEL_ID, ServerMessage } from "./protocol";
|
||||
|
||||
type ChatLine = { id: string; by: string; text: string; at: string };
|
||||
|
||||
function parseServerMessage(raw: unknown): ServerMessage | null {
|
||||
if (!raw || typeof raw !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const candidate = raw as { type?: unknown };
|
||||
if (typeof candidate.type !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return raw as ServerMessage;
|
||||
}
|
||||
|
||||
function defaultWsUrl(): string {
|
||||
const host = window.location.hostname || "127.0.0.1";
|
||||
return `ws://${host}:7777`;
|
||||
}
|
||||
|
||||
export function App() {
|
||||
const [wsUrl, setWsUrl] = useState(() => localStorage.getItem("bc.ws_url") ?? defaultWsUrl());
|
||||
const [socketState, setSocketState] = useState<"offline" | "connecting" | "online">("offline");
|
||||
const [authMode, setAuthMode] = useState<"login" | "register">("login");
|
||||
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
|
||||
const [me, setMe] = useState<{ id: string; username: string } | null>(null);
|
||||
const [channelInput, setChannelInput] = useState("");
|
||||
const [channelLines, setChannelLines] = useState<ChatLine[]>([]);
|
||||
|
||||
const [status, setStatus] = useState("Not connected");
|
||||
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const chatLogRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const ready = useMemo(() => socketState === "online", [socketState]);
|
||||
|
||||
useEffect(() => {
|
||||
if (chatLogRef.current) {
|
||||
chatLogRef.current.scrollTop = chatLogRef.current.scrollHeight;
|
||||
}
|
||||
}, [channelLines]);
|
||||
|
||||
function formatTime(timestamp: string): string {
|
||||
return new Date(timestamp).toLocaleTimeString([], {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
function send(message: ClientMessage) {
|
||||
const ws = wsRef.current;
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
||||
setStatus("WebSocket is not connected");
|
||||
return;
|
||||
}
|
||||
ws.send(JSON.stringify(message));
|
||||
}
|
||||
|
||||
function connect() {
|
||||
if (wsRef.current && wsRef.current.readyState <= WebSocket.OPEN) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSocketState("connecting");
|
||||
setStatus("Connecting...");
|
||||
localStorage.setItem("bc.ws_url", wsUrl);
|
||||
|
||||
const ws = new WebSocket(wsUrl);
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
setSocketState("online");
|
||||
setStatus("Connected");
|
||||
|
||||
const token = localStorage.getItem("bc.session_token");
|
||||
if (token) {
|
||||
send({ type: "resume_session", token });
|
||||
} else {
|
||||
setStatus("Connected. Please login or register.");
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
setSocketState("offline");
|
||||
setStatus("Disconnected");
|
||||
wsRef.current = null;
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
setStatus("WebSocket error");
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
const msg = parseServerMessage(JSON.parse(event.data));
|
||||
if (!msg) {
|
||||
setStatus("Invalid server message");
|
||||
return;
|
||||
}
|
||||
handleServer(msg);
|
||||
};
|
||||
}
|
||||
|
||||
function handleServer(msg: ServerMessage) {
|
||||
switch (msg.type) {
|
||||
case "auth_success": {
|
||||
localStorage.setItem("bc.session_token", msg.session_token);
|
||||
setMe({ id: msg.user_id, username: msg.username });
|
||||
setStatus(`Authenticated as ${msg.username}`);
|
||||
send({ type: "fetch_channel_history", channel_id: GENERAL_CHANNEL_ID, before_message_id: null, limit: 100 });
|
||||
return;
|
||||
}
|
||||
case "auth_error": {
|
||||
setStatus(`Auth error: ${msg.reason}`);
|
||||
return;
|
||||
}
|
||||
case "channel_history": {
|
||||
setChannelLines(
|
||||
[...msg.messages]
|
||||
.reverse()
|
||||
.map((m) => ({ id: m.message_id, by: m.author_username, text: m.content, at: m.timestamp })),
|
||||
);
|
||||
return;
|
||||
}
|
||||
case "channel_message": {
|
||||
if (msg.channel_id === GENERAL_CHANNEL_ID) {
|
||||
setChannelLines((prev) => [
|
||||
...prev,
|
||||
{ id: msg.message_id, by: msg.author_username, text: msg.content, at: msg.timestamp },
|
||||
]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
case "error": {
|
||||
setStatus(`Error ${msg.code}: ${msg.message}`);
|
||||
return;
|
||||
}
|
||||
default:
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function submitAuth(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
const identity = getOrCreateIdentityKey();
|
||||
|
||||
if (authMode === "login") {
|
||||
send({ type: "login", username, password, identity_pubkey: identity });
|
||||
return;
|
||||
}
|
||||
|
||||
send({ type: "register", username, password, identity_pubkey: identity });
|
||||
}
|
||||
|
||||
function submitChannel(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
if (!channelInput.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
send({ type: "send_channel_message", channel_id: GENERAL_CHANNEL_ID, content: channelInput.trim() });
|
||||
setChannelInput("");
|
||||
}
|
||||
|
||||
function logout() {
|
||||
send({ type: "logout" });
|
||||
localStorage.removeItem("bc.session_token");
|
||||
setMe(null);
|
||||
setStatus("Logged out");
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="layout">
|
||||
<section className="card topbar">
|
||||
<div>
|
||||
<h1>BackChannel</h1>
|
||||
<p>General chat room.</p>
|
||||
</div>
|
||||
|
||||
<div className="connection">
|
||||
<input value={wsUrl} onChange={(e) => setWsUrl(e.target.value)} placeholder="ws://127.0.0.1:7777" />
|
||||
<button onClick={connect} disabled={ready}>Connect</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="card status">{status}</section>
|
||||
|
||||
<section className="chat-shell">
|
||||
<article className="card panel chat-main">
|
||||
<h2>#general</h2>
|
||||
<form onSubmit={submitChannel}>
|
||||
<input value={channelInput} onChange={(e) => setChannelInput(e.target.value)} placeholder="message #general" />
|
||||
<button className="send-button" type="submit" disabled={!ready}>Send</button>
|
||||
</form>
|
||||
|
||||
<div ref={chatLogRef} className="log chat-log">
|
||||
{channelLines.map((line) => (
|
||||
<div key={line.id} className="line">
|
||||
<b>{line.by}</b>
|
||||
<span>{line.text}</span>
|
||||
<time>{formatTime(line.at)}</time>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<aside className="card panel auth-sidebar">
|
||||
<h2>Auth</h2>
|
||||
<div className="switch">
|
||||
<button className={authMode === "login" ? "active" : ""} onClick={() => setAuthMode("login")}>Login</button>
|
||||
<button className={authMode === "register" ? "active" : ""} onClick={() => setAuthMode("register")}>Register</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={submitAuth}>
|
||||
<input value={username} onChange={(e) => setUsername(e.target.value)} placeholder="username" />
|
||||
<input value={password} onChange={(e) => setPassword(e.target.value)} placeholder="password" type="password" />
|
||||
<button type="submit" disabled={!ready}>{authMode}</button>
|
||||
</form>
|
||||
|
||||
<div className="meta">
|
||||
<div>User: {me?.username ?? "(not authenticated)"}</div>
|
||||
<div>ID: {me?.id ?? "-"}</div>
|
||||
<button onClick={logout} disabled={!ready}>Logout</button>
|
||||
</div>
|
||||
</aside>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
69
backchannel-web/src/crypto.ts
Normal file
69
backchannel-web/src/crypto.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { chacha20poly1305 } from "@noble/ciphers/chacha";
|
||||
import { x25519 } from "@noble/curves/ed25519";
|
||||
import { hkdf } from "@noble/hashes/hkdf";
|
||||
import { sha256 } from "@noble/hashes/sha256";
|
||||
import { randomBytes } from "@noble/hashes/utils";
|
||||
|
||||
const HKDF_SALT = new TextEncoder().encode("backchannel-dm-salt-v1");
|
||||
const HKDF_INFO = new TextEncoder().encode("backchannel-dm-key-v1");
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
export function bytesToBase64(input: Uint8Array): string {
|
||||
let bin = "";
|
||||
for (const b of input) {
|
||||
bin += String.fromCharCode(b);
|
||||
}
|
||||
return btoa(bin);
|
||||
}
|
||||
|
||||
export function base64ToBytes(input: string): Uint8Array {
|
||||
const bin = atob(input);
|
||||
const bytes = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i += 1) {
|
||||
bytes[i] = bin.charCodeAt(i);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
export function getOrCreateIdentityKey(): string {
|
||||
const current = localStorage.getItem("bc.identity_key");
|
||||
if (current) {
|
||||
return current;
|
||||
}
|
||||
|
||||
const key = bytesToBase64(randomBytes(32));
|
||||
localStorage.setItem("bc.identity_key", key);
|
||||
return key;
|
||||
}
|
||||
|
||||
export function createEphemeralKeypair(): { privateKey: Uint8Array; publicKeyB64: string } {
|
||||
const privateKey = x25519.utils.randomPrivateKey();
|
||||
const publicKey = x25519.getPublicKey(privateKey);
|
||||
return { privateKey, publicKeyB64: bytesToBase64(publicKey) };
|
||||
}
|
||||
|
||||
export function deriveDmKey(privateKey: Uint8Array, theirPublicKeyB64: string): Uint8Array {
|
||||
const theirPublicKey = base64ToBytes(theirPublicKeyB64);
|
||||
const sharedSecret = x25519.getSharedSecret(privateKey, theirPublicKey);
|
||||
return hkdf(sha256, sharedSecret, HKDF_SALT, HKDF_INFO, 32);
|
||||
}
|
||||
|
||||
export function encryptDm(key: Uint8Array, plaintext: string): { nonce: string; ciphertext: string } {
|
||||
const nonceBytes = randomBytes(12);
|
||||
const cipher = chacha20poly1305(key, nonceBytes);
|
||||
const ciphertext = cipher.encrypt(encoder.encode(plaintext));
|
||||
return {
|
||||
nonce: bytesToBase64(nonceBytes),
|
||||
ciphertext: bytesToBase64(ciphertext),
|
||||
};
|
||||
}
|
||||
|
||||
export function decryptDm(key: Uint8Array, nonceB64: string, ciphertextB64: string): string {
|
||||
const nonceBytes = base64ToBytes(nonceB64);
|
||||
const ciphertext = base64ToBytes(ciphertextB64);
|
||||
const cipher = chacha20poly1305(key, nonceBytes);
|
||||
const plaintext = cipher.decrypt(ciphertext);
|
||||
return decoder.decode(plaintext);
|
||||
}
|
||||
11
backchannel-web/src/main.tsx
Normal file
11
backchannel-web/src/main.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
|
||||
import { App } from "./App";
|
||||
import "./styles.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
72
backchannel-web/src/protocol.ts
Normal file
72
backchannel-web/src/protocol.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
export const GENERAL_CHANNEL_ID = "00000000-0000-0000-0000-000000000010";
|
||||
|
||||
export type ClientMessage =
|
||||
| { type: "login"; username: string; password: string; identity_pubkey: string }
|
||||
| { type: "register"; username: string; password: string; identity_pubkey: string }
|
||||
| { type: "resume_session"; token: string }
|
||||
| { type: "logout" }
|
||||
| { type: "send_channel_message"; channel_id: string; content: string }
|
||||
| { type: "fetch_channel_history"; channel_id: string; before_message_id: string | null; limit: number }
|
||||
| { type: "init_dm_key_exchange"; recipient_id: string; sender_ephemeral_pubkey: string }
|
||||
| { type: "accept_dm_key_exchange"; initiator_id: string; recipient_ephemeral_pubkey: string }
|
||||
| { type: "send_dm"; recipient_id: string; ciphertext: string; nonce: string }
|
||||
| { type: "fetch_dm_history"; peer_id: string; before_message_id: string | null; limit: number }
|
||||
| { type: "ping" };
|
||||
|
||||
export type ServerMessage =
|
||||
| { type: "auth_success"; user_id: string; username: string; session_token: string }
|
||||
| { type: "auth_error"; reason: string }
|
||||
| {
|
||||
type: "channel_message";
|
||||
message_id: string;
|
||||
channel_id: string;
|
||||
author_id: string;
|
||||
author_username: string;
|
||||
content: string;
|
||||
timestamp: string;
|
||||
}
|
||||
| {
|
||||
type: "channel_history";
|
||||
channel_id: string;
|
||||
messages: Array<{
|
||||
message_id: string;
|
||||
author_id: string;
|
||||
author_username: string;
|
||||
content: string;
|
||||
timestamp: string;
|
||||
}>;
|
||||
}
|
||||
| {
|
||||
type: "dm_key_exchange_request";
|
||||
initiator_id: string;
|
||||
initiator_username: string;
|
||||
sender_ephemeral_pubkey: string;
|
||||
}
|
||||
| {
|
||||
type: "dm_key_exchange_response";
|
||||
recipient_id: string;
|
||||
recipient_username: string;
|
||||
recipient_ephemeral_pubkey: string;
|
||||
}
|
||||
| {
|
||||
type: "direct_message";
|
||||
message_id: string;
|
||||
sender_id: string;
|
||||
sender_username: string;
|
||||
ciphertext: string;
|
||||
nonce: string;
|
||||
timestamp: string;
|
||||
}
|
||||
| {
|
||||
type: "dm_history";
|
||||
peer_id: string;
|
||||
messages: Array<{
|
||||
message_id: string;
|
||||
sender_id: string;
|
||||
ciphertext: string;
|
||||
nonce: string;
|
||||
timestamp: string;
|
||||
}>;
|
||||
}
|
||||
| { type: "error"; code: number; message: string }
|
||||
| { type: "pong" };
|
||||
207
backchannel-web/src/styles.css
Normal file
207
backchannel-web/src/styles.css
Normal file
@@ -0,0 +1,207 @@
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--bg-0: #0d1b2a;
|
||||
--panel: #fffaf0;
|
||||
--text: #1d2d44;
|
||||
--accent: #e76f51;
|
||||
--accent-2: #2a9d8f;
|
||||
--line: #f4a261;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
background: radial-gradient(circle at top left, #415a77, var(--bg-0) 55%);
|
||||
font-family: "Space Grotesk", "Trebuchet MS", sans-serif;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.layout {
|
||||
width: min(1400px, 100vw);
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: linear-gradient(145deg, #fffaf0, #fdf0d5);
|
||||
border: 2px solid #e9c46a;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.topbar {
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
align-items: end;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-family: "Bebas Neue", "Impact", sans-serif;
|
||||
letter-spacing: 0.06em;
|
||||
font-size: clamp(2rem, 4vw, 2.8rem);
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-family: "Bebas Neue", "Impact", sans-serif;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 6px 0 0;
|
||||
}
|
||||
|
||||
.connection {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
min-width: min(540px, 100%);
|
||||
}
|
||||
|
||||
.status {
|
||||
padding: 10px 14px;
|
||||
font-weight: 700;
|
||||
border-color: var(--accent-2);
|
||||
}
|
||||
|
||||
.chat-shell {
|
||||
position: relative;
|
||||
padding-left: 350px;
|
||||
}
|
||||
|
||||
.panel {
|
||||
padding: 14px;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.chat-main {
|
||||
min-height: calc(100vh - 220px);
|
||||
}
|
||||
|
||||
.auth-sidebar {
|
||||
position: fixed;
|
||||
left: 20px;
|
||||
bottom: 20px;
|
||||
width: 320px;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.switch {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.switch .active {
|
||||
background: var(--accent-2);
|
||||
color: white;
|
||||
}
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
border: 2px solid #264653;
|
||||
border-radius: 10px;
|
||||
padding: 8px 10px;
|
||||
font: inherit;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
form {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
button {
|
||||
border: 0;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
border-radius: 10px;
|
||||
padding: 8px 10px;
|
||||
cursor: pointer;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.chat-main form {
|
||||
grid-template-columns: 1fr auto;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.send-button {
|
||||
padding: 6px 10px;
|
||||
min-width: 68px;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.meta {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.log {
|
||||
background: #fff;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
min-height: 200px;
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.chat-log {
|
||||
max-height: calc(100vh - 360px);
|
||||
}
|
||||
|
||||
.line {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
border-bottom: 1px dashed #c8d4dd;
|
||||
padding-bottom: 6px;
|
||||
}
|
||||
|
||||
time {
|
||||
font-size: 0.75rem;
|
||||
color: #526072;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.chat-shell {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.auth-sidebar {
|
||||
position: static;
|
||||
width: 100%;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.chat-main {
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
.chat-log {
|
||||
max-height: 50vh;
|
||||
}
|
||||
|
||||
.connection {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
1
backchannel-web/src/vite-env.d.ts
vendored
Normal file
1
backchannel-web/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user