feat: add hashtag channels from web UI (#725 M2)

- POST /api/channels/keys endpoint: accepts channel name, derives key
  via SHA256('#name')[:16], adds to runtime key manager
- GET /api/channels/keys: lists user-added channel keys
- DELETE /api/channels/keys/{name}: removes a user-added key
- Persistence: user_channel_keys SQLite table, loaded on startup
- Retroactive decryption: background goroutine scans undecrypted
  GRP_TXT packets and decrypts with new key
- Frontend: add channel input in channels sidebar with feedback
- PSK support: optional hex key parameter for non-hashtag channels
- Tests: key derivation, CRUD, API handlers, persistence, validation
This commit is contained in:
you
2026-04-13 06:33:35 +00:00
parent 71be54f085
commit 0d74589c65
6 changed files with 889 additions and 8 deletions
+321
View File
@@ -0,0 +1,321 @@
package main
import (
"crypto/aes"
"crypto/hmac"
"crypto/sha256"
"database/sql"
"encoding/binary"
"encoding/hex"
"encoding/json"
"fmt"
"log"
"strings"
"sync"
"unicode/utf8"
)
// UserChannelKey represents a user-added channel key stored in SQLite.
type UserChannelKey struct {
Name string `json:"name"`
KeyHex string `json:"key"`
Source string `json:"source"` // "hashtag" or "psk"
CreatedAt string `json:"created_at,omitempty"`
}
// ChannelKeyManager manages runtime channel decryption keys.
// Keys come from two sources: config (loaded by ingestor) and user-added (via API).
type ChannelKeyManager struct {
mu sync.RWMutex
keys map[string]string // name → hex key
}
// NewChannelKeyManager creates a new empty key manager.
func NewChannelKeyManager() *ChannelKeyManager {
return &ChannelKeyManager{keys: make(map[string]string)}
}
// AddKey adds or updates a channel key.
func (m *ChannelKeyManager) AddKey(name, keyHex string) {
m.mu.Lock()
defer m.mu.Unlock()
m.keys[name] = keyHex
}
// GetKeys returns a snapshot of all keys.
func (m *ChannelKeyManager) GetKeys() map[string]string {
m.mu.RLock()
defer m.mu.RUnlock()
cp := make(map[string]string, len(m.keys))
for k, v := range m.keys {
cp[k] = v
}
return cp
}
// RemoveKey removes a channel key by name. Returns true if it existed.
func (m *ChannelKeyManager) RemoveKey(name string) bool {
m.mu.Lock()
defer m.mu.Unlock()
_, existed := m.keys[name]
delete(m.keys, name)
return existed
}
// deriveHashtagKey derives an AES-128 key from a hashtag channel name.
// SHA-256(name)[:16] — name must include the # prefix.
func deriveHashtagKey(channelName string) string {
h := sha256.Sum256([]byte(channelName))
return hex.EncodeToString(h[:16])
}
// ensureUserChannelKeysTable creates the user_channel_keys table if it doesn't exist.
func ensureUserChannelKeysTable(dbPath string) error {
rw, err := openRW(dbPath)
if err != nil {
return fmt.Errorf("open RW for user_channel_keys: %w", err)
}
defer rw.Close()
_, err = rw.Exec(`CREATE TABLE IF NOT EXISTS user_channel_keys (
name TEXT PRIMARY KEY,
key_hex TEXT NOT NULL,
source TEXT NOT NULL DEFAULT 'hashtag',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)`)
return err
}
// loadUserChannelKeys loads all user-added channel keys from SQLite.
func loadUserChannelKeys(db *DB) ([]UserChannelKey, error) {
rows, err := db.conn.Query(`SELECT name, key_hex, source, created_at FROM user_channel_keys ORDER BY created_at`)
if err != nil {
// Table might not exist yet — not an error
if strings.Contains(err.Error(), "no such table") {
return nil, nil
}
return nil, err
}
defer rows.Close()
var keys []UserChannelKey
for rows.Next() {
var k UserChannelKey
if err := rows.Scan(&k.Name, &k.KeyHex, &k.Source, &k.CreatedAt); err != nil {
continue
}
keys = append(keys, k)
}
return keys, nil
}
// saveUserChannelKey persists a user-added channel key to SQLite.
func saveUserChannelKey(dbPath string, key UserChannelKey) error {
rw, err := openRW(dbPath)
if err != nil {
return err
}
defer rw.Close()
_, err = rw.Exec(
`INSERT OR REPLACE INTO user_channel_keys (name, key_hex, source) VALUES (?, ?, ?)`,
key.Name, key.KeyHex, key.Source,
)
return err
}
// deleteUserChannelKey removes a user-added channel key from SQLite.
func deleteUserChannelKey(dbPath string, name string) error {
rw, err := openRW(dbPath)
if err != nil {
return err
}
defer rw.Close()
_, err = rw.Exec(`DELETE FROM user_channel_keys WHERE name = ?`, name)
return err
}
// channelDecryptResult holds the result of decrypting a GRP_TXT message.
type channelDecryptResult struct {
Timestamp uint32
Flags byte
Sender string
Message string
}
// decryptChannelMessage decrypts a GRP_TXT payload using a channel key.
// Implements MeshCore channel decryption: HMAC-SHA256 MAC verification + AES-128-ECB.
func decryptChannelMessage(ciphertextHex, macHex, channelKeyHex string) (*channelDecryptResult, error) {
channelKey, err := hex.DecodeString(channelKeyHex)
if err != nil || len(channelKey) != 16 {
return nil, fmt.Errorf("invalid channel key")
}
macBytes, err := hex.DecodeString(macHex)
if err != nil || len(macBytes) != 2 {
return nil, fmt.Errorf("invalid MAC")
}
ciphertext, err := hex.DecodeString(ciphertextHex)
if err != nil || len(ciphertext) == 0 {
return nil, fmt.Errorf("invalid ciphertext")
}
// 32-byte channel secret: 16-byte key + 16 zero bytes
channelSecret := make([]byte, 32)
copy(channelSecret, channelKey)
// Verify HMAC-SHA256 (first 2 bytes must match provided MAC)
h := hmac.New(sha256.New, channelSecret)
h.Write(ciphertext)
calculatedMac := h.Sum(nil)
if calculatedMac[0] != macBytes[0] || calculatedMac[1] != macBytes[1] {
return nil, fmt.Errorf("MAC verification failed")
}
// AES-128-ECB decrypt (block-by-block, no padding)
if len(ciphertext)%aes.BlockSize != 0 {
return nil, fmt.Errorf("ciphertext not aligned to AES block size")
}
block, err := aes.NewCipher(channelKey)
if err != nil {
return nil, fmt.Errorf("AES cipher: %w", err)
}
plaintext := make([]byte, len(ciphertext))
for i := 0; i < len(ciphertext); i += aes.BlockSize {
block.Decrypt(plaintext[i:i+aes.BlockSize], ciphertext[i:i+aes.BlockSize])
}
// Parse: timestamp(4 LE) + flags(1) + message(UTF-8, null-terminated)
if len(plaintext) < 5 {
return nil, fmt.Errorf("decrypted content too short")
}
timestamp := binary.LittleEndian.Uint32(plaintext[0:4])
flags := plaintext[4]
messageText := string(plaintext[5:])
if idx := strings.IndexByte(messageText, 0); idx >= 0 {
messageText = messageText[:idx]
}
// Validate decrypted text is printable UTF-8 (not binary garbage)
if !utf8.ValidString(messageText) || countNonPrintable(messageText) > 2 {
return nil, fmt.Errorf("decrypted text contains non-printable characters")
}
result := &channelDecryptResult{Timestamp: timestamp, Flags: flags}
// Parse "sender: message" format
colonIdx := strings.Index(messageText, ": ")
if colonIdx > 0 && colonIdx < 50 {
potentialSender := messageText[:colonIdx]
if !strings.ContainsAny(potentialSender, ":[]") {
result.Sender = potentialSender
result.Message = messageText[colonIdx+2:]
} else {
result.Message = messageText
}
} else {
result.Message = messageText
}
return result, nil
}
// retroactiveDecrypt scans undecrypted GRP_TXT packets and attempts decryption
// with the given channel name and key. Returns the number of packets decrypted.
func retroactiveDecrypt(dbPath string, readDB *DB, channelName, keyHex string) (int, error) {
// Query undecrypted GRP_TXT packets (payload_type=5 for GRP_TXT)
rows, err := readDB.conn.Query(`
SELECT id, decoded_json FROM transmissions
WHERE payload_type = 5
AND (decoded_json IS NULL
OR decoded_json NOT LIKE '%"decryptionStatus":"decrypted"%')
ORDER BY first_seen DESC
LIMIT 10000
`)
if err != nil {
return 0, fmt.Errorf("query undecrypted packets: %w", err)
}
defer rows.Close()
type candidate struct {
id int64
decodedJSON sql.NullString
}
var candidates []candidate
for rows.Next() {
var c candidate
if err := rows.Scan(&c.id, &c.decodedJSON); err != nil {
continue
}
candidates = append(candidates, c)
}
if len(candidates) == 0 {
return 0, nil
}
// Open RW connection for updates
rw, err := openRW(dbPath)
if err != nil {
return 0, fmt.Errorf("open RW for retroactive decrypt: %w", err)
}
defer rw.Close()
decrypted := 0
for _, c := range candidates {
if !c.decodedJSON.Valid || c.decodedJSON.String == "" {
continue
}
var decoded map[string]interface{}
if err := json.Unmarshal([]byte(c.decodedJSON.String), &decoded); err != nil {
continue
}
// Get the encrypted data and MAC from the decoded JSON
encData, _ := decoded["encryptedData"].(string)
mac, _ := decoded["mac"].(string)
if encData == "" || mac == "" {
continue
}
// Skip if already decrypted
if status, _ := decoded["decryptionStatus"].(string); status == "decrypted" {
continue
}
// Attempt decryption
result, err := decryptChannelMessage(encData, mac, keyHex)
if err != nil {
continue
}
// Build updated decoded JSON
text := result.Message
if result.Sender != "" && result.Message != "" {
text = result.Sender + ": " + result.Message
}
decoded["type"] = "CHAN"
decoded["channel"] = channelName
decoded["decryptionStatus"] = "decrypted"
decoded["sender"] = result.Sender
decoded["text"] = text
decoded["sender_timestamp"] = result.Timestamp
updatedJSON, err := json.Marshal(decoded)
if err != nil {
continue
}
_, err = rw.Exec(`UPDATE transmissions SET decoded_json = ? WHERE id = ?`, string(updatedJSON), c.id)
if err != nil {
log.Printf("[channels] retroactive decrypt update failed for id=%d: %v", c.id, err)
continue
}
decrypted++
}
return decrypted, nil
}
+355
View File
@@ -0,0 +1,355 @@
package main
import (
"bytes"
"encoding/json"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/gorilla/mux"
)
func TestDeriveHashtagKey(t *testing.T) {
tests := []struct {
name string
want string
}{
{"#General", "649af2cab73ed5a890890a5485a0c004"},
{"#test", "9cd8fcf22a47333b591d96a2b848b73f"},
{"#MeshCore", "dcf73f393fa217f6b28fcec6ffc411ad"},
}
for _, tt := range tests {
got := deriveHashtagKey(tt.name)
if got != tt.want {
t.Errorf("deriveHashtagKey(%q) = %q, want %q", tt.name, got, tt.want)
}
}
}
func TestChannelKeyManager(t *testing.T) {
m := NewChannelKeyManager()
// Add keys
m.AddKey("#test", "abc123")
m.AddKey("#other", "def456")
keys := m.GetKeys()
if len(keys) != 2 {
t.Fatalf("expected 2 keys, got %d", len(keys))
}
if keys["#test"] != "abc123" {
t.Errorf("expected #test key abc123, got %s", keys["#test"])
}
// Remove key
existed := m.RemoveKey("#test")
if !existed {
t.Error("expected RemoveKey to return true")
}
existed = m.RemoveKey("#nonexistent")
if existed {
t.Error("expected RemoveKey to return false for nonexistent key")
}
keys = m.GetKeys()
if len(keys) != 1 {
t.Fatalf("expected 1 key after removal, got %d", len(keys))
}
}
func TestUserChannelKeysPersistence(t *testing.T) {
dir := t.TempDir()
dbPath := filepath.Join(dir, "test.db")
// Create the DB file with minimum schema
createTestDB(t, dbPath)
// Ensure table
if err := ensureUserChannelKeysTable(dbPath); err != nil {
t.Fatalf("ensureUserChannelKeysTable: %v", err)
}
// Save a key
key := UserChannelKey{Name: "#test", KeyHex: "abc123", Source: "hashtag"}
if err := saveUserChannelKey(dbPath, key); err != nil {
t.Fatalf("saveUserChannelKey: %v", err)
}
// Load keys via read-only connection
db, err := OpenDB(dbPath)
if err != nil {
t.Fatalf("OpenDB: %v", err)
}
defer db.Close()
keys, err := loadUserChannelKeys(db)
if err != nil {
t.Fatalf("loadUserChannelKeys: %v", err)
}
if len(keys) != 1 {
t.Fatalf("expected 1 key, got %d", len(keys))
}
if keys[0].Name != "#test" || keys[0].KeyHex != "abc123" || keys[0].Source != "hashtag" {
t.Errorf("unexpected key: %+v", keys[0])
}
// Delete key
if err := deleteUserChannelKey(dbPath, "#test"); err != nil {
t.Fatalf("deleteUserChannelKey: %v", err)
}
keys, err = loadUserChannelKeys(db)
if err != nil {
t.Fatalf("loadUserChannelKeys after delete: %v", err)
}
if len(keys) != 0 {
t.Errorf("expected 0 keys after delete, got %d", len(keys))
}
}
func createTestDB(t *testing.T, dbPath string) {
t.Helper()
rw, err := openRW(dbPath)
if err != nil {
t.Fatalf("openRW: %v", err)
}
defer rw.Close()
// Minimal schema for the server's OpenDB to work
_, err = rw.Exec(`
CREATE TABLE IF NOT EXISTS transmissions (
id INTEGER PRIMARY KEY,
raw_hex TEXT NOT NULL,
hash TEXT,
first_seen TEXT,
route_type INTEGER,
payload_type INTEGER,
payload_version INTEGER,
decoded_json TEXT
);
CREATE TABLE IF NOT EXISTS observations (
id INTEGER PRIMARY KEY,
transmission_id INTEGER,
observer_idx INTEGER,
timestamp TEXT,
snr REAL,
rssi REAL,
hops INTEGER,
path_json TEXT
);
CREATE TABLE IF NOT EXISTS nodes (
public_key TEXT PRIMARY KEY,
short_name TEXT,
long_name TEXT,
role TEXT,
lat REAL,
lon REAL,
last_seen TEXT,
advert_count INTEGER DEFAULT 0
);
CREATE TABLE IF NOT EXISTS observers (
id INTEGER PRIMARY KEY,
name TEXT,
iata TEXT
);
`)
if err != nil {
t.Fatalf("create schema: %v", err)
}
}
func TestHandleAddChannelKeyAPI(t *testing.T) {
dir := t.TempDir()
dbPath := filepath.Join(dir, "test.db")
createTestDB(t, dbPath)
if err := ensureUserChannelKeysTable(dbPath); err != nil {
t.Fatalf("ensureUserChannelKeysTable: %v", err)
}
db, err := OpenDB(dbPath)
if err != nil {
t.Fatalf("OpenDB: %v", err)
}
defer db.Close()
srv := NewServer(db, &Config{}, nil)
// Test adding a hashtag channel
body := `{"name":"#TestChannel"}`
req := httptest.NewRequest("POST", "/api/channels/keys", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
srv.handleAddChannelKey(w, req)
if w.Code != 200 {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &resp)
if resp["name"] != "#TestChannel" {
t.Errorf("expected name #TestChannel, got %v", resp["name"])
}
if resp["source"] != "hashtag" {
t.Errorf("expected source hashtag, got %v", resp["source"])
}
expectedKey := deriveHashtagKey("#TestChannel")
if resp["key"] != expectedKey {
t.Errorf("expected key %s, got %v", expectedKey, resp["key"])
}
// Verify it's in the runtime key manager
keys := srv.channelKeys.GetKeys()
if keys["#TestChannel"] != expectedKey {
t.Errorf("key not in runtime manager")
}
// Verify persistence
loaded, err := loadUserChannelKeys(db)
if err != nil {
t.Fatalf("loadUserChannelKeys: %v", err)
}
if len(loaded) != 1 || loaded[0].Name != "#TestChannel" {
t.Errorf("expected persisted key, got %+v", loaded)
}
}
func TestHandleAddChannelKeyAutoPrefix(t *testing.T) {
srv := NewServer(nil, &Config{}, nil)
body := `{"name":"NoHash"}`
req := httptest.NewRequest("POST", "/api/channels/keys", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
srv.handleAddChannelKey(w, req)
var resp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &resp)
if resp["name"] != "#NoHash" {
t.Errorf("expected auto-prefixed name #NoHash, got %v", resp["name"])
}
}
func TestHandleAddChannelKeyValidation(t *testing.T) {
srv := NewServer(nil, &Config{}, nil)
// Empty name
body := `{"name":""}`
req := httptest.NewRequest("POST", "/api/channels/keys", bytes.NewBufferString(body))
w := httptest.NewRecorder()
srv.handleAddChannelKey(w, req)
if w.Code != 400 {
t.Errorf("expected 400 for empty name, got %d", w.Code)
}
// Invalid key length
body = `{"name":"#test","key":"abc"}`
req = httptest.NewRequest("POST", "/api/channels/keys", bytes.NewBufferString(body))
w = httptest.NewRecorder()
srv.handleAddChannelKey(w, req)
if w.Code != 400 {
t.Errorf("expected 400 for short key, got %d", w.Code)
}
}
func TestHandleListChannelKeys(t *testing.T) {
dir := t.TempDir()
dbPath := filepath.Join(dir, "test.db")
createTestDB(t, dbPath)
ensureUserChannelKeysTable(dbPath)
saveUserChannelKey(dbPath, UserChannelKey{Name: "#a", KeyHex: "aaaa", Source: "hashtag"})
db, err := OpenDB(dbPath)
if err != nil {
t.Fatalf("OpenDB: %v", err)
}
defer db.Close()
srv := NewServer(db, &Config{}, nil)
req := httptest.NewRequest("GET", "/api/channels/keys", nil)
w := httptest.NewRecorder()
srv.handleListChannelKeys(w, req)
var resp struct {
Keys []UserChannelKey `json:"keys"`
}
json.Unmarshal(w.Body.Bytes(), &resp)
if len(resp.Keys) != 1 || resp.Keys[0].Name != "#a" {
t.Errorf("unexpected keys response: %+v", resp)
}
}
func TestHandleDeleteChannelKey(t *testing.T) {
dir := t.TempDir()
dbPath := filepath.Join(dir, "test.db")
createTestDB(t, dbPath)
ensureUserChannelKeysTable(dbPath)
saveUserChannelKey(dbPath, UserChannelKey{Name: "#del", KeyHex: "bbbb", Source: "hashtag"})
db, err := OpenDB(dbPath)
if err != nil {
t.Fatalf("OpenDB: %v", err)
}
defer db.Close()
srv := NewServer(db, &Config{}, nil)
srv.channelKeys.AddKey("#del", "bbbb")
// Use mux to inject path vars
router := mux.NewRouter()
router.HandleFunc("/api/channels/keys/{name}", srv.handleDeleteChannelKey).Methods("DELETE")
req := httptest.NewRequest("DELETE", "/api/channels/keys/del", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != 200 {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
// Verify removed from runtime
keys := srv.channelKeys.GetKeys()
if _, exists := keys["#del"]; exists {
t.Error("key should have been removed from runtime manager")
}
// Verify removed from DB
loaded, _ := loadUserChannelKeys(db)
if len(loaded) != 0 {
t.Errorf("expected 0 keys after delete, got %d", len(loaded))
}
}
func TestDecryptChannelMessageServer(t *testing.T) {
// Verify key derivation matches ingestor
key := deriveHashtagKey("#General")
if key != "649af2cab73ed5a890890a5485a0c004" {
t.Fatalf("unexpected #General key: %s", key)
}
// Verify the function handles invalid inputs gracefully
_, err := decryptChannelMessage("", "aabb", key)
if err == nil {
t.Error("expected error for empty ciphertext")
}
_, err = decryptChannelMessage("aabbccdd", "", key)
if err == nil {
t.Error("expected error for empty MAC")
}
_, err = decryptChannelMessage("aabbccdd", "aabb", "short")
if err == nil {
t.Error("expected error for invalid key")
}
}
// Ensure the test fixture DB path resolver works
func TestFixtureDBExists(t *testing.T) {
// This just verifies our test helper creates a usable DB
dir := t.TempDir()
dbPath := filepath.Join(dir, "test.db")
createTestDB(t, dbPath)
if _, err := os.Stat(dbPath); os.IsNotExist(err) {
t.Fatal("test DB was not created")
}
}
+17
View File
@@ -228,6 +228,23 @@ func main() {
// HTTP server
srv := NewServer(database, cfg, hub)
srv.store = store
// Initialize user channel keys table and load persisted keys
if err := ensureUserChannelKeysTable(dbPath); err != nil {
log.Printf("[channels] warning: could not create user_channel_keys table: %v", err)
}
if database != nil {
userKeys, err := loadUserChannelKeys(database)
if err != nil {
log.Printf("[channels] warning: could not load user channel keys: %v", err)
} else if len(userKeys) > 0 {
for _, k := range userKeys {
srv.channelKeys.AddKey(k.Name, k.KeyHex)
}
log.Printf("[channels] loaded %d user channel keys", len(userKeys))
}
}
router := mux.NewRouter()
srv.RegisterRoutes(router)
+132 -8
View File
@@ -3,6 +3,7 @@ package main
import (
"crypto/subtle"
"database/sql"
"encoding/hex"
"encoding/json"
"fmt"
"log"
@@ -46,6 +47,9 @@ type Server struct {
// Router reference for OpenAPI spec generation
router *mux.Router
// Channel key manager for runtime decryption keys
channelKeys *ChannelKeyManager
}
// PerfStats tracks request performance.
@@ -75,14 +79,15 @@ func NewPerfStats() *PerfStats {
func NewServer(db *DB, cfg *Config, hub *Hub) *Server {
return &Server{
db: db,
cfg: cfg,
hub: hub,
startedAt: time.Now(),
perfStats: NewPerfStats(),
version: resolveVersion(),
commit: resolveCommit(),
buildTime: resolveBuildTime(),
db: db,
cfg: cfg,
hub: hub,
startedAt: time.Now(),
perfStats: NewPerfStats(),
version: resolveVersion(),
commit: resolveCommit(),
buildTime: resolveBuildTime(),
channelKeys: NewChannelKeyManager(),
}
}
@@ -160,6 +165,9 @@ func (s *Server) RegisterRoutes(r *mux.Router) {
// Other endpoints
r.HandleFunc("/api/resolve-hops", s.handleResolveHops).Methods("GET")
r.HandleFunc("/api/channels/keys", s.handleAddChannelKey).Methods("POST")
r.HandleFunc("/api/channels/keys", s.handleListChannelKeys).Methods("GET")
r.HandleFunc("/api/channels/keys/{name}", s.handleDeleteChannelKey).Methods("DELETE")
r.HandleFunc("/api/channels/{hash}/messages", s.handleChannelMessages).Methods("GET")
r.HandleFunc("/api/channels", s.handleChannels).Methods("GET")
r.HandleFunc("/api/observers/metrics/summary", s.handleMetricsSummary).Methods("GET")
@@ -1697,6 +1705,122 @@ func (s *Server) handleChannelMessages(w http.ResponseWriter, r *http.Request) {
writeJSON(w, ChannelMessagesResponse{Messages: []map[string]interface{}{}, Total: 0})
}
// handleAddChannelKey adds a new channel key (hashtag-derived or PSK).
func (s *Server) handleAddChannelKey(w http.ResponseWriter, r *http.Request) {
var req struct {
Name string `json:"name"`
Key string `json:"key,omitempty"` // optional hex PSK
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, 400, "invalid JSON body")
return
}
name := strings.TrimSpace(req.Name)
if name == "" {
writeError(w, 400, "name is required")
return
}
// Ensure hashtag prefix
if !strings.HasPrefix(name, "#") {
name = "#" + name
}
var keyHex string
source := "hashtag"
if req.Key != "" {
// Validate hex key (must be 32 hex chars = 16 bytes)
if len(req.Key) != 32 {
writeError(w, 400, "key must be 32 hex characters (16 bytes)")
return
}
if _, err := hex.DecodeString(req.Key); err != nil {
writeError(w, 400, "key must be valid hex")
return
}
keyHex = req.Key
source = "psk"
} else {
keyHex = deriveHashtagKey(name)
}
// Add to runtime key manager
s.channelKeys.AddKey(name, keyHex)
// Persist to SQLite
if s.db != nil {
if err := saveUserChannelKey(s.db.path, UserChannelKey{
Name: name,
KeyHex: keyHex,
Source: source,
}); err != nil {
log.Printf("[channels] failed to persist key for %s: %v", name, err)
}
}
// Retroactive decryption in background
if s.db != nil {
go func() {
count, err := retroactiveDecrypt(s.db.path, s.db, name, keyHex)
if err != nil {
log.Printf("[channels] retroactive decrypt error: %v", err)
} else if count > 0 {
log.Printf("[channels] retroactively decrypted %d packets for %s", count, name)
}
}()
}
writeJSON(w, map[string]interface{}{
"ok": true,
"name": name,
"key": keyHex,
"source": source,
"message": fmt.Sprintf("Added %s — decrypting matching messages...", name),
})
}
// handleListChannelKeys returns all user-added channel keys.
func (s *Server) handleListChannelKeys(w http.ResponseWriter, r *http.Request) {
if s.db == nil {
writeJSON(w, map[string]interface{}{"keys": []UserChannelKey{}})
return
}
keys, err := loadUserChannelKeys(s.db)
if err != nil {
writeError(w, 500, err.Error())
return
}
if keys == nil {
keys = []UserChannelKey{}
}
writeJSON(w, map[string]interface{}{"keys": keys})
}
// handleDeleteChannelKey removes a user-added channel key.
func (s *Server) handleDeleteChannelKey(w http.ResponseWriter, r *http.Request) {
name := mux.Vars(r)["name"]
if name == "" {
writeError(w, 400, "name is required")
return
}
// URL-decode: the name may contain # which is URL-encoded
if !strings.HasPrefix(name, "#") {
name = "#" + name
}
s.channelKeys.RemoveKey(name)
if s.db != nil {
if err := deleteUserChannelKey(s.db.path, name); err != nil {
log.Printf("[channels] failed to delete key for %s: %v", name, err)
}
}
writeJSON(w, map[string]interface{}{"ok": true, "name": name})
}
func (s *Server) handleObservers(w http.ResponseWriter, r *http.Request) {
observers, err := s.db.GetObservers()
if err != nil {
+48
View File
@@ -327,6 +327,13 @@
<div class="ch-sidebar-header">
<div class="ch-sidebar-title"><span class="ch-icon">💬</span> Channels</div>
</div>
<div class="ch-add-channel" id="chAddChannel">
<form id="chAddForm" class="ch-add-form" autocomplete="off">
<input type="text" id="chAddInput" class="ch-add-input" placeholder="Enter #channelname" aria-label="Add channel" />
<button type="submit" class="ch-add-btn" title="Add channel">+</button>
</form>
<div id="chAddFeedback" class="ch-add-feedback" style="display:none"></div>
</div>
<div id="chRegionFilter" class="region-filter-container" style="padding:0 8px"></div>
<div class="ch-channel-list" id="chList" role="listbox" aria-label="Channels">
<div class="ch-loading">Loading channels</div>
@@ -355,6 +362,47 @@
});
loadObserverRegions();
// Add channel form handler
(function () {
var form = document.getElementById('chAddForm');
var input = document.getElementById('chAddInput');
var feedback = document.getElementById('chAddFeedback');
if (!form) return;
form.addEventListener('submit', async function (e) {
e.preventDefault();
var val = (input.value || '').trim();
if (!val) return;
// Auto-add # prefix if missing
if (val.charAt(0) !== '#') val = '#' + val;
feedback.style.display = 'block';
feedback.textContent = 'Adding ' + val + '…';
feedback.className = 'ch-add-feedback';
try {
var resp = await fetch('/api/channels/keys', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: val })
});
var data = await resp.json();
if (!resp.ok) {
feedback.textContent = data.error || 'Failed to add channel';
feedback.className = 'ch-add-feedback ch-add-error';
return;
}
feedback.textContent = data.message || ('Added ' + val);
feedback.className = 'ch-add-feedback ch-add-success';
input.value = '';
// Refresh channel list after a short delay (retroactive decryption is async)
setTimeout(function () { loadChannels(); }, 1500);
setTimeout(function () { feedback.style.display = 'none'; }, 4000);
} catch (err) {
feedback.textContent = 'Network error';
feedback.className = 'ch-add-feedback ch-add-error';
}
});
})();
loadChannels().then(async function () {
if (routeParam) await selectChannel(routeParam);
if (_pendingNode && _pendingNode.length < 200) await showNodeDetail(_pendingNode);
+16
View File
@@ -460,6 +460,22 @@ fieldset.mc-section legend.mc-label { padding: 0; }
.ch-sidebar-header {
padding: 14px 16px; border-bottom: 1px solid var(--border);
}
.ch-add-channel { padding: 8px 12px; border-bottom: 1px solid var(--border); }
.ch-add-form { display: flex; gap: 4px; }
.ch-add-input {
flex: 1; padding: 6px 10px; border: 1px solid var(--border); border-radius: 6px;
font-size: 13px; background: var(--input-bg, var(--bg)); color: var(--text); font-family: var(--font);
}
.ch-add-input::placeholder { color: var(--text-secondary, #999); }
.ch-add-btn {
padding: 6px 12px; border: 1px solid var(--border); border-radius: 6px;
background: var(--accent, #4a9eff); color: #fff; font-size: 16px; font-weight: 700;
cursor: pointer; line-height: 1;
}
.ch-add-btn:hover { opacity: 0.85; }
.ch-add-feedback { font-size: 12px; padding: 4px 0 0; color: var(--text-secondary, #999); }
.ch-add-feedback.ch-add-success { color: var(--success, #4caf50); }
.ch-add-feedback.ch-add-error { color: var(--error, #f44336); }
.ch-sidebar-title {
display: flex; align-items: center; gap: 8px; font-size: 16px; font-weight: 700; margin-bottom: 8px;
}