First commit in rust

This commit is contained in:
2026-02-19 13:51:07 +08:00
commit c37ca9ba52
73 changed files with 9737 additions and 0 deletions

24
backchannel-web/README.md Normal file
View File

@@ -0,0 +1,24 @@
# BackChannel Web
React + TypeScript browser client for BackChannel.
## Development
```powershell
cd backchannel-web
npm install
npm run dev
```
Default WebSocket target in the app is `ws://<host>:7777` and can be changed from the UI.
## Build for Embedded Server
```powershell
cd backchannel-web
npm install
npm run build
```
The Rust server embeds files from `backchannel-web/dist` at compile time.
Rebuild the Rust server after producing a new web build.

View File

@@ -0,0 +1 @@
: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 #0003}.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:.06em;font-size:clamp(2rem,4vw,2.8rem)}h2{margin:0;font-family:Bebas Neue,Impact,sans-serif;letter-spacing:.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:#fff}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:.5;cursor:not-allowed}.meta{display:grid;gap:6px;font-size:.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:.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}}

File diff suppressed because one or more lines are too long

13
backchannel-web/dist/index.html vendored Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>BackChannel</title>
<script type="module" crossorigin src="/assets/index-DEokFOhy.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-C3Y0u3Sh.css">
</head>
<body>
<div id="root"></div>
</body>
</html>

View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>BackChannel</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

1768
backchannel-web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,25 @@
{
"name": "backchannel-web",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"@noble/ciphers": "^1.3.0",
"@noble/curves": "^1.8.0",
"@noble/hashes": "^1.8.0",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@types/react": "^18.3.11",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.2",
"typescript": "^5.6.3",
"vite": "^5.4.8"
}
}

236
backchannel-web/src/App.tsx Normal file
View 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>
);
}

View 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);
}

View 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>,
);

View 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" };

View 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
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "Bundler",
"allowImportingTsExtensions": false,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}

View File

@@ -0,0 +1 @@
{"root":["./src/app.tsx","./src/crypto.ts","./src/main.tsx","./src/protocol.ts","./src/vite-env.d.ts"],"version":"5.9.3"}

View File

@@ -0,0 +1,6 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
});