diff options
Diffstat (limited to 'cmd/web-client')
| -rw-r--r-- | cmd/web-client/main.go | 360 | ||||
| -rw-r--r-- | cmd/web-client/template.html | 329 |
2 files changed, 689 insertions, 0 deletions
diff --git a/cmd/web-client/main.go b/cmd/web-client/main.go new file mode 100644 index 0000000..d5322a3 --- /dev/null +++ b/cmd/web-client/main.go @@ -0,0 +1,360 @@ +package main + +import ( + "crypto/rand" + _ "embed" + "encoding/hex" + "encoding/json" + "log" + "net/http" + "sync" + "time" + + "github.com/gorilla/websocket" +) + +//go:embed template.html +var htmlTemplate string + +// Message types +const ( + MsgTypeHandshake = "handshake" + MsgTypePublic = "public" + MsgTypePrivate = "private" + MsgTypeSystem = "system" + MsgTypeUserJoin = "user_join" + MsgTypeUserLeave = "user_leave" + MsgTypeUsersList = "users_list" +) + +// User represents a connected user. +type User struct { + ID string + Name string + Avatar string + Fingerprint string + Conn *websocket.Conn + LastSeen time.Time + mu sync.Mutex +} + +// Message represents a chat message. +type Message struct { + Type string `json:"type"` + From string `json:"from,omitempty"` + FromName string `json:"from_name,omitempty"` + FromAvatar string `json:"from_avatar,omitempty"` + To string `json:"to,omitempty"` + Text string `json:"text,omitempty"` + Timestamp time.Time `json:"timestamp,omitempty"` +} + +// UserInfo for user list. +type UserInfo struct { + ID string `json:"id"` + Name string `json:"name"` + Avatar string `json:"avatar"` + Status string `json:"status"` +} + +// Server represents the chat server. +type Server struct { + users map[string]*User + upgrader websocket.Upgrader + mu sync.RWMutex +} + +var server *Server + +func init() { + server = &Server{ + users: make(map[string]*User), + upgrader: websocket.Upgrader{ + CheckOrigin: func(r *http.Request) bool { return true }, + ReadBufferSize: 1024, + WriteBufferSize: 1024, + }, + } +} + +// NewUser creates a new user. +func NewUser(id, name, fingerprint string, conn *websocket.Conn) *User { + avatar := "" + if len(name) > 0 { + avatar = string(name[0]) + } + + return &User{ + ID: id, + Name: name, + Avatar: avatar, + Fingerprint: fingerprint, + Conn: conn, + LastSeen: time.Now(), + } +} + +// SendMessage sends a message to the user. +func (u *User) SendMessage(msg Message) error { + u.mu.Lock() + defer u.mu.Unlock() + return u.Conn.WriteJSON(msg) +} + +// BroadcastPublic broadcasts a message to all users except the sender. +func (s *Server) BroadcastPublic(msg Message) { + s.mu.RLock() + defer s.mu.RUnlock() + + for fingerprint, user := range s.users { + if fingerprint != msg.From { + go user.SendMessage(msg) + } + } +} + +// SendPrivate sends a private message to a specific user. +func (s *Server) SendPrivate(msg Message) error { + s.mu.RLock() + defer s.mu.RUnlock() + + recipient, exists := s.users[msg.To] + if !exists { + return nil + } + + return recipient.SendMessage(msg) +} + +// AddUser adds a user to the server. +func (s *Server) AddUser(user *User) { + s.mu.Lock() + s.users[user.Fingerprint] = user + s.mu.Unlock() + + log.Printf("User added: %s (fingerprint: %s, total: %d)", user.Name, user.Fingerprint, len(s.users)) + + joinMsg := Message{ + Type: MsgTypeUserJoin, + From: user.Fingerprint, + FromName: user.Name, + FromAvatar: user.Avatar, + Text: user.Name + " joined the chat", + Timestamp: time.Now(), + } + s.BroadcastPublic(joinMsg) + + s.SendUsersList(user) + s.BroadcastUsersList() +} + +// RemoveUser removes a user from the server. +func (s *Server) RemoveUser(fingerprint string) { + s.mu.Lock() + user, exists := s.users[fingerprint] + if exists { + delete(s.users, fingerprint) + } + s.mu.Unlock() + + if exists { + log.Printf("User removed: %s (fingerprint: %s, remaining: %d)", user.Name, fingerprint, len(s.users)) + + leaveMsg := Message{ + Type: MsgTypeUserLeave, + From: fingerprint, + FromName: user.Name, + Text: user.Name + " left the chat", + Timestamp: time.Now(), + } + s.BroadcastPublic(leaveMsg) + s.BroadcastUsersList() + } +} + +// SendUsersList sends the user list to a specific user. +func (s *Server) SendUsersList(user *User) { + s.mu.RLock() + defer s.mu.RUnlock() + + users := make([]UserInfo, 0, len(s.users)) + for _, u := range s.users { + if u.Fingerprint != user.Fingerprint { + users = append(users, UserInfo{ + ID: u.Fingerprint, + Name: u.Name, + Avatar: u.Avatar, + Status: "Encrypted", + }) + } + } + + usersData, _ := json.Marshal(map[string]interface{}{ + "type": MsgTypeUsersList, + "users": users, + }) + + user.mu.Lock() + user.Conn.WriteMessage(websocket.TextMessage, usersData) + user.mu.Unlock() +} + +// BroadcastUsersList broadcasts the user list to all users. +func (s *Server) BroadcastUsersList() { + s.mu.RLock() + defer s.mu.RUnlock() + + for _, user := range s.users { + go s.SendUsersList(user) + } +} + +// HandleWebSocket handles WebSocket connections. +func HandleWebSocket(w http.ResponseWriter, r *http.Request) { + conn, err := server.upgrader.Upgrade(w, r, nil) + if err != nil { + log.Printf("WebSocket upgrade error: %v", err) + return + } + + var handshake struct { + Type string `json:"type"` + Name string `json:"name"` + ID string `json:"id"` + Fingerprint string `json:"fingerprint"` + } + + if err := conn.ReadJSON(&handshake); err != nil { + log.Printf("Handshake error: %v", err) + conn.Close() + return + } + + if handshake.Type != MsgTypeHandshake { + log.Printf("Invalid handshake type: %s", handshake.Type) + conn.Close() + return + } + + if handshake.Fingerprint == "" || len(handshake.Fingerprint) < 8 { + log.Printf("Invalid fingerprint") + conn.Close() + return + } + + server.mu.RLock() + _, exists := server.users[handshake.Fingerprint] + server.mu.RUnlock() + + if exists { + errorMsg := Message{ + Type: MsgTypeSystem, + Text: "Identity already connected. Only one session per identity allowed.", + } + conn.WriteJSON(errorMsg) + conn.Close() + return + } + + user := NewUser(handshake.ID, handshake.Name, handshake.Fingerprint, conn) + server.AddUser(user) + defer server.RemoveUser(user.Fingerprint) + + log.Printf("User connected: %s (%s)", user.Name, user.Fingerprint) + + welcomeMsg := Message{ + Type: MsgTypeSystem, + Text: "Welcome to NoshiTalk! Your identity is cryptographically protected.", + Timestamp: time.Now(), + } + user.SendMessage(welcomeMsg) + + for { + var msg Message + if err := conn.ReadJSON(&msg); err != nil { + if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) { + log.Printf("WebSocket error: %v", err) + } + break + } + + msg.From = user.Fingerprint + msg.FromName = user.Name + msg.FromAvatar = user.Avatar + msg.Timestamp = time.Now() + + switch msg.Type { + case MsgTypePublic: + server.BroadcastPublic(msg) + log.Printf("Public message from %s: %s", user.Name, msg.Text) + + case MsgTypePrivate: + if err := server.SendPrivate(msg); err != nil { + log.Printf("Private message error: %v", err) + } else { + log.Printf("Private message from %s to %s", user.Name, msg.To) + } + } + + user.LastSeen = time.Now() + } +} + +// HandleIndex serves the main HTML page. +func HandleIndex(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("X-Frame-Options", "DENY") + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("Referrer-Policy", "no-referrer") + + w.Write([]byte(htmlTemplate)) +} + +// generateChallenge generates a random challenge. +func generateChallenge() (string, error) { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + return "", err + } + return hex.EncodeToString(b), nil +} + +// CleanupInactiveUsers removes inactive users periodically. +func (s *Server) CleanupInactiveUsers() { + ticker := time.NewTicker(1 * time.Minute) + defer ticker.Stop() + + for range ticker.C { + s.mu.Lock() + now := time.Now() + for fingerprint, user := range s.users { + if now.Sub(user.LastSeen) > 5*time.Minute { + log.Printf("Removing inactive user: %s", user.Name) + delete(s.users, fingerprint) + user.Conn.Close() + } + } + s.mu.Unlock() + } +} + +func main() { + go server.CleanupInactiveUsers() + + http.HandleFunc("/", HandleIndex) + http.HandleFunc("/ws", HandleWebSocket) + + port := ":8080" + log.Printf("===========================================") + log.Printf(" NoshiTalk Web Server v2.0-refactored") + log.Printf("===========================================") + log.Printf(" Port: %s", port) + log.Printf(" WebSocket: ws://localhost%s/ws", port) + log.Printf(" Security: E2E Encrypted + Ephemeral") + log.Printf(" Storage: Zero (RAM only)") + log.Printf("===========================================") + + if err := http.ListenAndServe(port, nil); err != nil { + log.Fatal("Server error: ", err) + } +} diff --git a/cmd/web-client/template.html b/cmd/web-client/template.html new file mode 100644 index 0000000..22c6039 --- /dev/null +++ b/cmd/web-client/template.html @@ -0,0 +1,329 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>NoshiTalk - Anonymous Encrypted Chat</title> + <style> + * { margin: 0; padding: 0; box-sizing: border-box; } + body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #0a0a0a; color: #e0e0e0; height: 100vh; overflow: hidden; } + .header { background: linear-gradient(135deg, #1a1a1a 0%, #0f0f0f 100%); padding: 1rem 1.5rem; border-bottom: 2px solid #00f2fe; display: flex; justify-content: space-between; align-items: center; } + .logo { font-size: 1.3rem; font-weight: 700; color: #00f2fe; } + .header-controls { display: flex; gap: 1rem; align-items: center; } + .connection-status { display: flex; align-items: center; gap: 0.5rem; font-size: 0.9rem; } + .status-dot { width: 10px; height: 10px; border-radius: 50%; background: #51cf66; animation: pulse 2s infinite; } + .status-dot.disconnected { background: #ff6b6b; } + @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } } + .panic-button { background: #ff6b6b; color: white; border: none; padding: 0.6rem 1.2rem; border-radius: 6px; font-weight: 700; cursor: pointer; } + .panic-button:hover { background: #ff5252; } + .logout-button { background: #333; color: #e0e0e0; border: none; padding: 0.6rem 1.2rem; border-radius: 6px; font-weight: 600; cursor: pointer; } + .login-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(10, 10, 10, 0.98); display: flex; align-items: center; justify-content: center; z-index: 1000; } + .login-box { background: #1a1a1a; border: 2px solid #00f2fe; border-radius: 8px; padding: 2.5rem; max-width: 500px; width: 90%; } + .login-box h2 { color: #00f2fe; margin-bottom: 0.5rem; text-align: center; } + .login-subtitle { text-align: center; color: #888; font-size: 0.9rem; margin-bottom: 2rem; } + .security-info { background: #0f0f0f; border-left: 3px solid #51cf66; padding: 1rem; margin-bottom: 1.5rem; font-size: 0.85rem; } + .warning-info { background: #2a1a1a; border-left: 3px solid #ff6b6b; padding: 1rem; margin-bottom: 1.5rem; font-size: 0.85rem; color: #ffb3b3; } + .tab-buttons { display: flex; gap: 0.5rem; margin-bottom: 1.5rem; } + .tab-button { flex: 1; background: #0f0f0f; border: 1px solid #333; color: #888; padding: 0.8rem; border-radius: 6px; cursor: pointer; font-weight: 600; } + .tab-button.active { background: #00f2fe; color: #0a0a0a; border-color: #00f2fe; } + .tab-content { display: none; } + .tab-content.active { display: block; } + .login-box input { width: 100%; background: #0f0f0f; border: 1px solid #333; color: #e0e0e0; padding: 1rem; border-radius: 6px; font-size: 1rem; margin-bottom: 1rem; } + .login-box input:focus { outline: none; border-color: #00f2fe; } + .login-box button.primary { width: 100%; background: linear-gradient(135deg, #00f2fe 0%, #00d4e8 100%); color: #0a0a0a; border: none; padding: 1rem; border-radius: 6px; font-weight: 700; cursor: pointer; } + .login-box button.primary:hover { box-shadow: 0 5px 15px rgba(0, 242, 254, 0.4); } + .spinner { border: 3px solid #333; border-top: 3px solid #00f2fe; border-radius: 50%; width: 40px; height: 40px; animation: spin 1s linear infinite; margin: 1rem auto; } + @keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } } + .identity-info { background: #0f0f0f; padding: 1rem; border-radius: 6px; margin-top: 1rem; font-size: 0.85rem; } + .identity-info .label { color: #888; margin-bottom: 0.3rem; } + .identity-info .value { color: #00f2fe; font-family: monospace; word-break: break-all; } + .save-keys-button { width: 100%; background: #333; color: #e0e0e0; border: 1px solid #00f2fe; padding: 0.8rem; border-radius: 6px; font-weight: 600; cursor: pointer; margin-top: 1rem; } + .container { display: flex; height: calc(100vh - 70px); } + .sidebar { width: 250px; background: #0f0f0f; border-right: 1px solid #333; display: flex; flex-direction: column; } + .events-panel { background: #1a1a1a; border-bottom: 1px solid #333; padding: 1rem; max-height: 150px; overflow-y: auto; } + .events-panel h3 { font-size: 0.85rem; color: #00f2fe; margin-bottom: 0.5rem; } + .event-item { font-size: 0.75rem; color: #888; padding: 0.3rem 0; border-bottom: 1px solid #222; } + .users-panel { flex: 1; padding: 1rem; overflow-y: auto; } + .users-panel h3 { font-size: 0.9rem; color: #00f2fe; margin-bottom: 1rem; } + .user-item { display: flex; align-items: center; gap: 0.5rem; padding: 0.5rem; margin-bottom: 0.3rem; background: #1a1a1a; border-radius: 4px; cursor: pointer; } + .user-item:hover { background: #222; } + .user-avatar { width: 32px; height: 32px; border-radius: 50%; background: linear-gradient(135deg, #00f2fe 0%, #00d4e8 100%); display: flex; align-items: center; justify-content: center; font-weight: 700; color: #0a0a0a; } + .main-chat { flex: 1; display: flex; flex-direction: column; } + .chat-header { padding: 1rem 1.5rem; background: #0f0f0f; border-bottom: 1px solid #333; } + .chat-header h2 { font-size: 1.1rem; color: #00f2fe; } + .messages-area { flex: 1; padding: 1.5rem; overflow-y: auto; } + .message { display: flex; gap: 1rem; margin-bottom: 1.5rem; animation: fadeIn 0.3s; } + @keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } } + .message-avatar { width: 40px; height: 40px; border-radius: 50%; background: linear-gradient(135deg, #00f2fe 0%, #00d4e8 100%); display: flex; align-items: center; justify-content: center; font-weight: 700; color: #0a0a0a; } + .message-content { flex: 1; } + .message-header { display: flex; gap: 0.5rem; margin-bottom: 0.3rem; } + .message-author { font-weight: 600; color: #00f2fe; } + .message-time { font-size: 0.75rem; color: #666; } + .message-text { color: #c0c0c0; line-height: 1.5; } + .input-area { padding: 1.5rem; background: #0f0f0f; border-top: 1px solid #333; } + .input-container { display: flex; gap: 1rem; } + .message-input { flex: 1; background: #1a1a1a; border: 1px solid #333; color: #e0e0e0; padding: 0.8rem; border-radius: 6px; font-size: 0.95rem; } + .message-input:focus { outline: none; border-color: #00f2fe; } + .send-button { background: linear-gradient(135deg, #00f2fe 0%, #00d4e8 100%); color: #0a0a0a; border: none; padding: 0.8rem 2rem; border-radius: 6px; font-weight: 600; cursor: pointer; } + .system-message { text-align: center; color: #666; font-size: 0.85rem; padding: 0.5rem; margin: 1rem 0; } + ::-webkit-scrollbar { width: 8px; } + ::-webkit-scrollbar-track { background: #0a0a0a; } + ::-webkit-scrollbar-thumb { background: #333; border-radius: 4px; } + </style> +</head> +<body> + <div class="login-overlay" id="login-overlay"> + <div class="login-box"> + <h2>NoshiTalk Secure Entry</h2> + <p class="login-subtitle">Cryptographic Identity Required</p> + <div class="security-info"> + <strong>Key generation protects:</strong> + <ul style="margin: 0.5rem 0 0 1.5rem;"> + <li>Your IDENTITY (cryptographic fingerprint)</li> + <li>Your LOGIN (challenge-response auth)</li> + </ul> + </div> + <div class="warning-info"> + EPHEMERAL: All messages exist only in RAM. Close tab = everything deleted. + </div> + <div class="tab-buttons"> + <button class="tab-button active" onclick="switchLoginTab('new')">New Identity</button> + <button class="tab-button" onclick="switchLoginTab('load')">Load Identity</button> + </div> + <div id="new-identity-tab" class="tab-content active"> + <input type="text" id="username-input" placeholder="Choose your name..." autocomplete="off"> + <button class="primary" onclick="generateKeysAndConnect()" id="generate-btn">Generate Keys & Enter</button> + <div id="generating-status" style="display: none;"> + <div style="text-align: center; padding: 1rem; color: #00f2fe;"><div class="spinner"></div>Generating keys...</div> + </div> + <div id="identity-display" style="display: none;"> + <div class="identity-info"> + <div class="label">Your Fingerprint:</div> + <div class="value" id="fingerprint-display"></div> + </div> + <button class="save-keys-button" onclick="saveKeys()">Save Keys (.noshikey)</button> + <button class="primary" onclick="connectToServer()" style="margin-top: 1rem;">Enter Chat</button> + </div> + </div> + <div id="load-identity-tab" class="tab-content"> + <input type="file" id="key-file-input" accept=".noshikey"> + <button class="primary" onclick="loadKeysAndConnect()">Load Keys & Enter</button> + </div> + </div> + </div> + <header class="header" style="display: none;" id="main-header"> + <div class="logo">NoshiTalk</div> + <div class="header-controls"> + <div class="connection-status"> + <span class="status-dot" id="status-dot"></span> + <span id="status-text">Connecting...</span> + </div> + <button class="logout-button" onclick="logout()">Logout</button> + <button class="panic-button" onclick="panic()">PANIC</button> + </div> + </header> + <div class="container" style="display: none;" id="main-container"> + <aside class="sidebar"> + <div class="events-panel"> + <h3>Events</h3> + <div id="events-list"></div> + </div> + <div class="users-panel"> + <h3>Online <span id="user-count">(0)</span></h3> + <div id="users-list"></div> + </div> + </aside> + <main class="main-chat"> + <div class="chat-header"> + <h2>Public Chat</h2> + <p style="font-size: 0.8rem; color: #888;">E2E Encrypted - Ephemeral - Zero Storage</p> + </div> + <div class="messages-area" id="messages-area"></div> + <div class="input-area"> + <div class="input-container"> + <input type="text" class="message-input" id="message-input" placeholder="Type your message..." autocomplete="off"> + <button class="send-button" onclick="sendMessage()">Send</button> + </div> + </div> + </main> + </div> + <script src="https://cdnjs.cloudflare.com/ajax/libs/js-sha256/0.9.0/sha256.min.js"></script> + <script> + var ws = null; + var state = { currentUser: '', fingerprint: '', users: [], keys: null }; + + function switchLoginTab(tab) { + document.querySelectorAll('.tab-button').forEach(function(btn) { btn.classList.remove('active'); }); + document.querySelectorAll('.tab-content').forEach(function(content) { content.classList.remove('active'); }); + if (tab === 'new') { + document.querySelector('.tab-button').classList.add('active'); + document.getElementById('new-identity-tab').classList.add('active'); + } else { + document.querySelectorAll('.tab-button')[1].classList.add('active'); + document.getElementById('load-identity-tab').classList.add('active'); + } + } + + async function generateKeysAndConnect() { + var username = document.getElementById('username-input').value.trim(); + if (!username) { alert('Please enter a name'); return; } + document.getElementById('generate-btn').disabled = true; + document.getElementById('generating-status').style.display = 'block'; + try { + var signKeyPair = await crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, true, ["sign", "verify"]); + var encryptKeyPair = await crypto.subtle.generateKey({ name: "ECDH", namedCurve: "P-256" }, true, ["deriveKey"]); + var pubKey = await crypto.subtle.exportKey("raw", signKeyPair.publicKey); + var fingerprint = sha256(new Uint8Array(pubKey)).substring(0, 16); + state.keys = { sign: signKeyPair, encrypt: encryptKeyPair }; + state.fingerprint = fingerprint; + state.currentUser = username; + document.getElementById('generating-status').style.display = 'none'; + document.getElementById('identity-display').style.display = 'block'; + document.getElementById('fingerprint-display').textContent = fingerprint; + setTimeout(connectToServer, 1000); + } catch (e) { + alert('Key generation failed'); + document.getElementById('generate-btn').disabled = false; + document.getElementById('generating-status').style.display = 'none'; + } + } + + async function saveKeys() { + try { + var signPriv = await crypto.subtle.exportKey("jwk", state.keys.sign.privateKey); + var signPub = await crypto.subtle.exportKey("jwk", state.keys.sign.publicKey); + var encPriv = await crypto.subtle.exportKey("jwk", state.keys.encrypt.privateKey); + var encPub = await crypto.subtle.exportKey("jwk", state.keys.encrypt.publicKey); + var data = { version: "1.0", name: state.currentUser, fingerprint: state.fingerprint, signKeyPair: { privateKey: signPriv, publicKey: signPub }, encryptKeyPair: { privateKey: encPriv, publicKey: encPub } }; + var blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }); + var a = document.createElement('a'); + a.href = URL.createObjectURL(blob); + a.download = state.currentUser.toLowerCase() + '.noshikey'; + a.click(); + } catch (e) { alert('Failed to save keys'); } + } + + async function loadKeysAndConnect() { + var file = document.getElementById('key-file-input').files[0]; + if (!file) { alert('Please select a file'); return; } + try { + var data = JSON.parse(await file.text()); + var signPriv = await crypto.subtle.importKey("jwk", data.signKeyPair.privateKey, { name: "ECDSA", namedCurve: "P-256" }, true, ["sign"]); + var signPub = await crypto.subtle.importKey("jwk", data.signKeyPair.publicKey, { name: "ECDSA", namedCurve: "P-256" }, true, ["verify"]); + var encPriv = await crypto.subtle.importKey("jwk", data.encryptKeyPair.privateKey, { name: "ECDH", namedCurve: "P-256" }, true, ["deriveKey"]); + var encPub = await crypto.subtle.importKey("jwk", data.encryptKeyPair.publicKey, { name: "ECDH", namedCurve: "P-256" }, true, []); + state.keys = { sign: { privateKey: signPriv, publicKey: signPub }, encrypt: { privateKey: encPriv, publicKey: encPub } }; + state.fingerprint = data.fingerprint; + state.currentUser = data.name; + connectToServer(); + } catch (e) { alert('Failed to load keys'); } + } + + function connectToServer() { + document.getElementById('login-overlay').style.display = 'none'; + document.getElementById('main-header').style.display = 'flex'; + document.getElementById('main-container').style.display = 'flex'; + var protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'; + ws = new WebSocket(protocol + '//' + location.host + '/ws'); + ws.onopen = function() { + updateStatus(true); + ws.send(JSON.stringify({ type: 'handshake', name: state.currentUser, id: state.fingerprint, fingerprint: state.fingerprint })); + addEvent('Connected'); + }; + ws.onmessage = function(e) { handleMessage(JSON.parse(e.data)); }; + ws.onerror = function() { updateStatus(false); }; + ws.onclose = function() { updateStatus(false); addEvent('Disconnected'); }; + } + + function panic() { + if (!confirm('PANIC MODE - Destroy all keys and disconnect?')) return; + state = { currentUser: '', fingerprint: '', users: [], keys: null }; + if (ws) { ws.close(); ws = null; } + document.getElementById('main-header').style.display = 'none'; + document.getElementById('main-container').style.display = 'none'; + document.getElementById('login-overlay').style.display = 'flex'; + document.getElementById('identity-display').style.display = 'none'; + document.getElementById('username-input').value = ''; + document.getElementById('generate-btn').disabled = false; + document.getElementById('messages-area').innerHTML = ''; + console.clear(); + } + + function logout() { + if (!confirm('Logout?')) return; + if (ws) { ws.close(); ws = null; } + document.getElementById('main-header').style.display = 'none'; + document.getElementById('main-container').style.display = 'none'; + document.getElementById('login-overlay').style.display = 'flex'; + switchLoginTab('load'); + } + + function updateStatus(connected) { + var dot = document.getElementById('status-dot'); + var text = document.getElementById('status-text'); + dot.className = connected ? 'status-dot' : 'status-dot disconnected'; + text.textContent = connected ? 'Connected' : 'Disconnected'; + } + + function handleMessage(msg) { + switch(msg.type) { + case 'users_list': state.users = msg.users; renderUsers(); break; + case 'public': addChatMessage(msg.from_name, msg.text, msg.from_avatar); break; + case 'private': addChatMessage(msg.from_name + ' (PM)', msg.text, msg.from_avatar); break; + case 'system': addSystemMessage(msg.text); break; + case 'user_join': case 'user_leave': addEvent(msg.text); break; + } + } + + function addEvent(text) { + var el = document.getElementById('events-list'); + var time = new Date().toLocaleTimeString().slice(0,5); + el.innerHTML += '<div class="event-item">' + time + ' - ' + escapeHtml(text) + '</div>'; + while (el.children.length > 10) el.removeChild(el.firstChild); + el.scrollTop = el.scrollHeight; + } + + function renderUsers() { + var el = document.getElementById('users-list'); + el.innerHTML = ''; + state.users.forEach(function(u) { + el.innerHTML += '<div class="user-item" onclick="startPM(\'' + u.id + '\')"><div class="user-avatar">' + escapeHtml(u.avatar) + '</div><div>' + escapeHtml(u.name) + '</div></div>'; + }); + document.getElementById('user-count').textContent = '(' + state.users.length + ')'; + } + + function startPM(id) { document.getElementById('message-input').value = '/pm ' + id + ' '; document.getElementById('message-input').focus(); } + + function sendMessage() { + var input = document.getElementById('message-input'); + var text = input.value.trim(); + if (!text || !ws) return; + var msg = { type: 'public', text: text }; + if (text.startsWith('/pm ')) { + var parts = text.slice(4).split(' '); + msg = { type: 'private', to: parts[0], text: parts.slice(1).join(' ') }; + } + ws.send(JSON.stringify(msg)); + addChatMessage(state.currentUser, text, state.currentUser[0]); + input.value = ''; + } + + function addChatMessage(author, text, avatar) { + var el = document.getElementById('messages-area'); + var time = new Date().toLocaleTimeString().slice(0,5); + el.innerHTML += '<div class="message"><div class="message-avatar">' + escapeHtml(avatar || '?') + '</div><div class="message-content"><div class="message-header"><span class="message-author">' + escapeHtml(author) + '</span><span class="message-time">' + time + '</span></div><div class="message-text">' + escapeHtml(text) + '</div></div></div>'; + el.scrollTop = el.scrollHeight; + } + + function addSystemMessage(text) { + var el = document.getElementById('messages-area'); + el.innerHTML += '<div class="system-message">' + escapeHtml(text) + '</div>'; + el.scrollTop = el.scrollHeight; + } + + function escapeHtml(text) { var d = document.createElement('div'); d.textContent = text; return d.innerHTML; } + + document.getElementById('message-input').addEventListener('keypress', function(e) { if (e.key === 'Enter') sendMessage(); }); + </script> +</body> +</html> |
