Initial commit: end-to-end encrypted kid-friendly group chat
A single-feed group chat with no logins or passwords — access is via secret invite links only. Messages are end-to-end encrypted: the shared AES-GCM key lives in each link's #fragment and never reaches the server, which stores and relays opaque ciphertext blobs only. - Go single binary (net/http, coder/websocket, pure-Go SQLite, //go:embed) - Admin invite management (create/list/revoke) gated by an admin token - Join flow: open link, pick a screen name, join - Real-time feed over WebSockets with history + reconnect/gap-fill - All encryption client-side via Web Crypto
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
15 files changed,
+1534
−0
.gitignore+15 −0README.md+85 −0db.go+263 −0go.mod+24 −0go.sum+51 −0hub.go+138 −0main.go+231 −0web/pages/admin.html+49 −0web/pages/index.html+36 −0web/pages/join.html+28 −0web/static/admin.js+176 −0web/static/chat.js+142 −0web/static/crypto.js+59 −0web/static/join.js+65 −0web/static/style.css+172 −0
diff --git a/.gitignore b/.gitignore| new file mode 100644 |
| index 0000000..3a53da5 |
| --- /dev/null |
| +++ b/.gitignore |
| @@ -0,0 +1,15 @@ |
| +# Compiled binary |
| +/lchat |
| + |
| +# SQLite database (contains user data — never commit) |
| +*.db |
| +*.db-wal |
| +*.db-shm |
| + |
| +# macOS |
| +.DS_Store |
| + |
| +# Editor/IDE |
| +*.swp |
| +.idea/ |
| +.vscode/ |
diff --git a/README.md b/README.md| new file mode 100644 |
| index 0000000..510399e |
| --- /dev/null |
| +++ b/README.md |
| @@ -0,0 +1,85 @@ |
| +# lchat |
| + |
| +A tiny, kid-friendly group chat. One shared feed, no logins, no passwords — |
| +just secret invite links. Messages are **end-to-end encrypted**: the server |
| +only ever sees ciphertext, so compromising the server reveals nothing. |
| + |
| +## How the encryption works |
| + |
| +- There is one shared **AES-GCM key** for the chat. |
| +- The key lives in the `#fragment` of each invite link (e.g. |
| + `/join/<token>#k=<key>`). Browsers never send the fragment to the server. |
| +- The browser encrypts every message (including the sender's screen name) |
| + before sending, and decrypts on receipt. The server stores and relays only |
| + opaque blobs. |
| +- The admin token authorises invite management only. Even with it — or with the |
| + whole database and server — an attacker cannot read messages without a link. |
| + |
| +**Trade-offs (inherent to this model):** |
| + |
| +- Anyone who gets a link can read the whole feed — the link *is* the key. |
| +- If the key is lost everywhere (all browsers cleared, no backup), the history |
| + is permanently unreadable. The admin page shows the key with a **Copy key** |
| + button — back it up. |
| +- Revoking an invite cuts that link's access to the *server* (no new messages, |
| + can't post). It can't claw back the key it already carried. |
| + |
| +## Run it |
| + |
| +```sh |
| +go run . # listens on :1747, creates lchat.db |
| +``` |
| + |
| +On first start it prints an **admin link** containing your admin token — |
| +open it once and keep it secret: |
| + |
| +``` |
| +Admin link (open once, keep it secret): |
| +http://localhost:1747/admin#token=... |
| +``` |
| + |
| +Flags / env: |
| + |
| +- `-addr :1747` — listen address |
| +- `-db lchat.db` — database file path |
| +- `-reset-admin` — generate and print a fresh admin token |
| +- `ADMIN_TOKEN=...` — set a fixed admin token instead of a generated one |
| + |
| +## Build a single binary |
| + |
| +The frontend and the SQLite engine are compiled in (pure-Go SQLite, no cgo), |
| +so deployment is one file: |
| + |
| +```sh |
| +go build -o lchat . # native |
| +GOOS=linux GOARCH=amd64 go build -o lchat . # cross-compile for a Linux box |
| +``` |
| + |
| +Copy `lchat` to your server and run it behind a TLS-terminating reverse proxy |
| +(Caddy/nginx). **HTTPS is required in production** — the invite token travels to |
| +the server, and the fragment-key model assumes the transport is encrypted. |
| + |
| +## Flow |
| + |
| +1. **Admin** opens the admin link, gets the chat's encryption key (generated in |
| + the browser), and creates invites. Each invite → a secret link. |
| +2. **A guest** opens their link, picks a screen name, and joins. |
| +3. Everyone shares **one feed**, like an iMessage group chat. |
| + |
| +## Layout |
| + |
| +``` |
| +main.go HTTP routing, handlers, admin bootstrap, //go:embed |
| +db.go SQLite: config, invites, messages (ciphertext only) |
| +hub.go WebSocket fan-out hub + message ingest |
| +web/pages/*.html chat / join / admin pages |
| +web/static/* crypto.js (Web Crypto), chat.js, join.js, admin.js, style.css |
| +``` |
| + |
| +## Not in this first slice (candidates for next) |
| + |
| +- Rate limiting / spam protection on message posting |
| +- Key backup file export & nicer recovery UX |
| +- Typing indicators, read state, message deletion |
| +- Image/file sharing (encrypted) |
| +- Per-message sender identity that survives a screen-name change |
diff --git a/db.go b/db.go| new file mode 100644 |
| index 0000000..13a0b35 |
| --- /dev/null |
| +++ b/db.go |
| @@ -0,0 +1,263 @@ |
| +package main |
| + |
| +import ( |
| + "crypto/rand" |
| + "crypto/sha256" |
| + "crypto/subtle" |
| + "database/sql" |
| + "encoding/base64" |
| + "encoding/hex" |
| + "time" |
| + |
| + _ "modernc.org/sqlite" |
| +) |
| + |
| +// DB wraps the SQLite connection. Note: the server never stores message |
| +// plaintext or any encryption key. Messages live here only as opaque |
| +// ciphertext blobs produced by the browser. |
| +type DB struct{ sql *sql.DB } |
| + |
| +func openDB(path string) (*DB, error) { |
| + d, err := sql.Open("sqlite", path) |
| + if err != nil { |
| + return nil, err |
| + } |
| + // One writer at a time keeps WAL happy and avoids "database is locked". |
| + d.SetMaxOpenConns(1) |
| + for _, p := range []string{ |
| + "PRAGMA journal_mode=WAL", |
| + "PRAGMA busy_timeout=5000", |
| + "PRAGMA synchronous=NORMAL", |
| + "PRAGMA foreign_keys=ON", |
| + } { |
| + if _, err := d.Exec(p); err != nil { |
| + return nil, err |
| + } |
| + } |
| + db := &DB{sql: d} |
| + if err := db.migrate(); err != nil { |
| + return nil, err |
| + } |
| + return db, nil |
| +} |
| + |
| +func (db *DB) migrate() error { |
| + _, err := db.sql.Exec(` |
| +CREATE TABLE IF NOT EXISTS config ( |
| + key TEXT PRIMARY KEY, |
| + value TEXT NOT NULL |
| +); |
| +CREATE TABLE IF NOT EXISTS invites ( |
| + token TEXT PRIMARY KEY, |
| + label TEXT NOT NULL DEFAULT '', |
| + created_at INTEGER NOT NULL, |
| + claimed_at INTEGER, |
| + revoked INTEGER NOT NULL DEFAULT 0 |
| +); |
| +CREATE TABLE IF NOT EXISTS messages ( |
| + id INTEGER PRIMARY KEY AUTOINCREMENT, |
| + created_at INTEGER NOT NULL, |
| + blob TEXT NOT NULL |
| +);`) |
| + return err |
| +} |
| + |
| +// ---- config ------------------------------------------------------------- |
| + |
| +func (db *DB) getConfig(key string) (string, bool, error) { |
| + var v string |
| + err := db.sql.QueryRow("SELECT value FROM config WHERE key = ?", key).Scan(&v) |
| + if err == sql.ErrNoRows { |
| + return "", false, nil |
| + } |
| + if err != nil { |
| + return "", false, err |
| + } |
| + return v, true, nil |
| +} |
| + |
| +func (db *DB) setConfig(key, value string) error { |
| + _, err := db.sql.Exec( |
| + "INSERT INTO config(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value", |
| + key, value, |
| + ) |
| + return err |
| +} |
| + |
| +// ---- admin token -------------------------------------------------------- |
| +// |
| +// The admin token only authorises invite management. It is NOT an encryption |
| +// key — even with it, an attacker cannot read messages. We store only its hash. |
| + |
| +func hashToken(t string) string { |
| + h := sha256.Sum256([]byte(t)) |
| + return hex.EncodeToString(h[:]) |
| +} |
| + |
| +func randToken() (string, error) { |
| + b := make([]byte, 32) |
| + if _, err := rand.Read(b); err != nil { |
| + return "", err |
| + } |
| + return base64.RawURLEncoding.EncodeToString(b), nil |
| +} |
| + |
| +// ensureAdminToken sets up the admin token. If env is non-empty it is used. |
| +// Otherwise, if none exists yet, a fresh one is generated. Returns the |
| +// plaintext token only when a new one was generated (so it can be printed once). |
| +func (db *DB) ensureAdminToken(env string) (string, error) { |
| + if env != "" { |
| + return "", db.setConfig("admin_token_hash", hashToken(env)) |
| + } |
| + if _, ok, err := db.getConfig("admin_token_hash"); err != nil || ok { |
| + return "", err |
| + } |
| + tok, err := randToken() |
| + if err != nil { |
| + return "", err |
| + } |
| + return tok, db.setConfig("admin_token_hash", hashToken(tok)) |
| +} |
| + |
| +func (db *DB) resetAdminToken() (string, error) { |
| + tok, err := randToken() |
| + if err != nil { |
| + return "", err |
| + } |
| + return tok, db.setConfig("admin_token_hash", hashToken(tok)) |
| +} |
| + |
| +func (db *DB) checkAdmin(token string) bool { |
| + want, ok, err := db.getConfig("admin_token_hash") |
| + if err != nil || !ok { |
| + return false |
| + } |
| + return subtle.ConstantTimeCompare([]byte(hashToken(token)), []byte(want)) == 1 |
| +} |
| + |
| +// ---- invites ------------------------------------------------------------ |
| + |
| +type Invite struct { |
| + Token string `json:"token"` |
| + Label string `json:"label"` |
| + CreatedAt int64 `json:"created_at"` |
| + ClaimedAt *int64 `json:"claimed_at"` |
| + Revoked bool `json:"revoked"` |
| +} |
| + |
| +func (db *DB) createInvite(label string) (Invite, error) { |
| + tok, err := randToken() |
| + if err != nil { |
| + return Invite{}, err |
| + } |
| + now := time.Now().Unix() |
| + _, err = db.sql.Exec( |
| + "INSERT INTO invites(token, label, created_at) VALUES(?, ?, ?)", |
| + tok, label, now, |
| + ) |
| + if err != nil { |
| + return Invite{}, err |
| + } |
| + return Invite{Token: tok, Label: label, CreatedAt: now}, nil |
| +} |
| + |
| +func (db *DB) listInvites() ([]Invite, error) { |
| + rows, err := db.sql.Query( |
| + "SELECT token, label, created_at, claimed_at, revoked FROM invites ORDER BY created_at DESC", |
| + ) |
| + if err != nil { |
| + return nil, err |
| + } |
| + defer rows.Close() |
| + out := []Invite{} |
| + for rows.Next() { |
| + var inv Invite |
| + var revoked int |
| + if err := rows.Scan(&inv.Token, &inv.Label, &inv.CreatedAt, &inv.ClaimedAt, &revoked); err != nil { |
| + return nil, err |
| + } |
| + inv.Revoked = revoked != 0 |
| + out = append(out, inv) |
| + } |
| + return out, rows.Err() |
| +} |
| + |
| +// inviteStatus reports whether a token exists and whether it is revoked. |
| +func (db *DB) inviteStatus(token string) (exists, revoked bool, err error) { |
| + var r int |
| + err = db.sql.QueryRow("SELECT revoked FROM invites WHERE token = ?", token).Scan(&r) |
| + if err == sql.ErrNoRows { |
| + return false, false, nil |
| + } |
| + if err != nil { |
| + return false, false, err |
| + } |
| + return true, r != 0, nil |
| +} |
| + |
| +// claimInvite marks the first use of an invite. Reusable afterwards (so a kid |
| +// can reopen the link on another device) unless revoked. |
| +func (db *DB) claimInvite(token string) error { |
| + _, err := db.sql.Exec( |
| + "UPDATE invites SET claimed_at = COALESCE(claimed_at, ?) WHERE token = ? AND revoked = 0", |
| + time.Now().Unix(), token, |
| + ) |
| + return err |
| +} |
| + |
| +func (db *DB) revokeInvite(token string) error { |
| + _, err := db.sql.Exec("UPDATE invites SET revoked = 1 WHERE token = ?", token) |
| + return err |
| +} |
| + |
| +// ---- messages ----------------------------------------------------------- |
| + |
| +type Message struct { |
| + ID int64 `json:"id"` |
| + CreatedAt int64 `json:"created_at"` |
| + Blob string `json:"blob"` |
| +} |
| + |
| +func (db *DB) insertMessage(blob string) (Message, error) { |
| + now := time.Now().Unix() |
| + res, err := db.sql.Exec("INSERT INTO messages(created_at, blob) VALUES(?, ?)", now, blob) |
| + if err != nil { |
| + return Message{}, err |
| + } |
| + id, err := res.LastInsertId() |
| + if err != nil { |
| + return Message{}, err |
| + } |
| + return Message{ID: id, CreatedAt: now, Blob: blob}, nil |
| +} |
| + |
| +// messagesAfter returns messages with id > after (ascending). When after is 0, |
| +// it returns the most recent `limit` messages instead of the entire history. |
| +func (db *DB) messagesAfter(after int64, limit int) ([]Message, error) { |
| + var rows *sql.Rows |
| + var err error |
| + if after == 0 { |
| + rows, err = db.sql.Query( |
| + "SELECT id, created_at, blob FROM (SELECT id, created_at, blob FROM messages ORDER BY id DESC LIMIT ?) ORDER BY id ASC", |
| + limit, |
| + ) |
| + } else { |
| + rows, err = db.sql.Query( |
| + "SELECT id, created_at, blob FROM messages WHERE id > ? ORDER BY id ASC LIMIT ?", |
| + after, limit, |
| + ) |
| + } |
| + if err != nil { |
| + return nil, err |
| + } |
| + defer rows.Close() |
| + out := []Message{} |
| + for rows.Next() { |
| + var m Message |
| + if err := rows.Scan(&m.ID, &m.CreatedAt, &m.Blob); err != nil { |
| + return nil, err |
| + } |
| + out = append(out, m) |
| + } |
| + return out, rows.Err() |
| +} |
diff --git a/go.mod b/go.mod| new file mode 100644 |
| index 0000000..97094a8 |
| --- /dev/null |
| +++ b/go.mod |
| @@ -0,0 +1,24 @@ |
| +module lchat |
| + |
| +go 1.23 |
| + |
| +require ( |
| + github.com/coder/websocket v1.8.12 |
| + modernc.org/sqlite v1.34.4 |
| +) |
| + |
| +require ( |
| + github.com/dustin/go-humanize v1.0.1 // indirect |
| + github.com/google/uuid v1.6.0 // indirect |
| + github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect |
| + github.com/mattn/go-isatty v0.0.20 // indirect |
| + github.com/ncruces/go-strftime v0.1.9 // indirect |
| + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect |
| + golang.org/x/sys v0.22.0 // indirect |
| + modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect |
| + modernc.org/libc v1.55.3 // indirect |
| + modernc.org/mathutil v1.6.0 // indirect |
| + modernc.org/memory v1.8.0 // indirect |
| + modernc.org/strutil v1.2.0 // indirect |
| + modernc.org/token v1.1.0 // indirect |
| +) |
diff --git a/go.sum b/go.sum| new file mode 100644 |
| index 0000000..3ad8c28 |
| --- /dev/null |
| +++ b/go.sum |
| @@ -0,0 +1,51 @@ |
| +github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo= |
| +github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs= |
| +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= |
| +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= |
| +github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo= |
| +github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw= |
| +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= |
| +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= |
| +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= |
| +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= |
| +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= |
| +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= |
| +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= |
| +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= |
| +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= |
| +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= |
| +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= |
| +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= |
| +golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic= |
| +golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= |
| +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= |
| +golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= |
| +golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= |
| +golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw= |
| +golang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc= |
| +modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ= |
| +modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ= |
| +modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y= |
| +modernc.org/ccgo/v4 v4.19.2/go.mod h1:ysS3mxiMV38XGRTTcgo0DQTeTmAO4oCmJl1nX9VFI3s= |
| +modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE= |
| +modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ= |
| +modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw= |
| +modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU= |
| +modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI= |
| +modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4= |
| +modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U= |
| +modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w= |
| +modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= |
| +modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= |
| +modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E= |
| +modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU= |
| +modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4= |
| +modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= |
| +modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc= |
| +modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss= |
| +modernc.org/sqlite v1.34.4 h1:sjdARozcL5KJBvYQvLlZEmctRgW9xqIZc2ncN7PU0P8= |
| +modernc.org/sqlite v1.34.4/go.mod h1:3QQFCG2SEMtc2nv+Wq4cQCH7Hjcg+p/RMlS1XK+zwbk= |
| +modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA= |
| +modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0= |
| +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= |
| +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= |
diff --git a/hub.go b/hub.go| new file mode 100644 |
| index 0000000..dc7070c |
| --- /dev/null |
| +++ b/hub.go |
| @@ -0,0 +1,138 @@ |
| +package main |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "log" |
| + "net/http" |
| + "time" |
| + |
| + "github.com/coder/websocket" |
| +) |
| + |
| +// maxBlobBytes caps a single ciphertext payload. Plenty for a chat line plus |
| +// AES-GCM overhead; keeps a misbehaving client from flooding the DB. |
| +const maxBlobBytes = 64 * 1024 |
| + |
| +// client is one connected browser. send is the per-client outbound queue; the |
| +// hub never blocks on a slow client — it drops them instead. |
| +type client struct { |
| + send chan []byte |
| +} |
| + |
| +// Hub fans out ciphertext blobs to every connected client. It only ever |
| +// handles opaque bytes — it cannot read message contents. |
| +type Hub struct { |
| + register chan *client |
| + unregister chan *client |
| + broadcast chan []byte |
| + clients map[*client]bool |
| +} |
| + |
| +func newHub() *Hub { |
| + return &Hub{ |
| + register: make(chan *client), |
| + unregister: make(chan *client), |
| + broadcast: make(chan []byte, 256), |
| + clients: make(map[*client]bool), |
| + } |
| +} |
| + |
| +func (h *Hub) run() { |
| + for { |
| + select { |
| + case c := <-h.register: |
| + h.clients[c] = true |
| + case c := <-h.unregister: |
| + if h.clients[c] { |
| + delete(h.clients, c) |
| + close(c.send) |
| + } |
| + case b := <-h.broadcast: |
| + for c := range h.clients { |
| + select { |
| + case c.send <- b: |
| + default: |
| + // Backed-up client: drop it. Its read loop will clean up. |
| + delete(h.clients, c) |
| + close(c.send) |
| + } |
| + } |
| + } |
| + } |
| +} |
| + |
| +type wsIn struct { |
| + Type string `json:"type"` |
| + Blob string `json:"blob"` |
| +} |
| + |
| +type wsOut struct { |
| + Type string `json:"type"` |
| + ID int64 `json:"id"` |
| + CreatedAt int64 `json:"created_at"` |
| + Blob string `json:"blob"` |
| +} |
| + |
| +func (s *Server) handleWS(w http.ResponseWriter, r *http.Request) { |
| + token := r.URL.Query().Get("token") |
| + exists, revoked, err := s.db.inviteStatus(token) |
| + if err != nil || !exists || revoked { |
| + http.Error(w, "invalid invite", http.StatusForbidden) |
| + return |
| + } |
| + |
| + conn, err := websocket.Accept(w, r, nil) // same-origin check is on by default |
| + if err != nil { |
| + return |
| + } |
| + conn.SetReadLimit(maxBlobBytes + 1024) |
| + |
| + cl := &client{send: make(chan []byte, 32)} |
| + s.hub.register <- cl |
| + |
| + ctx := r.Context() |
| + |
| + // Writer: drains this client's queue. Closing send (by the hub) ends it. |
| + go func() { |
| + defer conn.CloseNow() |
| + for b := range cl.send { |
| + wctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) |
| + err := conn.Write(wctx, websocket.MessageText, b) |
| + cancel() |
| + if err != nil { |
| + return |
| + } |
| + } |
| + }() |
| + |
| + // Reader: accepts new ciphertext, stores it, and broadcasts to everyone. |
| + defer func() { |
| + s.hub.unregister <- cl |
| + conn.CloseNow() |
| + }() |
| + for { |
| + _, data, err := conn.Read(ctx) |
| + if err != nil { |
| + return |
| + } |
| + var in wsIn |
| + if json.Unmarshal(data, &in) != nil { |
| + continue |
| + } |
| + if in.Type != "msg" || in.Blob == "" || len(in.Blob) > maxBlobBytes { |
| + continue |
| + } |
| + // Re-check revocation so a revoked client can't keep posting. |
| + if _, rev, err := s.db.inviteStatus(token); err != nil || rev { |
| + return |
| + } |
| + m, err := s.db.insertMessage(in.Blob) |
| + if err != nil { |
| + log.Println("insert message:", err) |
| + continue |
| + } |
| + out, _ := json.Marshal(wsOut{Type: "msg", ID: m.ID, CreatedAt: m.CreatedAt, Blob: m.Blob}) |
| + s.hub.broadcast <- out |
| + } |
| +} |
diff --git a/main.go b/main.go| new file mode 100644 |
| index 0000000..04794e1 |
| --- /dev/null |
| +++ b/main.go |
| @@ -0,0 +1,231 @@ |
| +package main |
| + |
| +import ( |
| + "embed" |
| + "encoding/json" |
| + "flag" |
| + "io/fs" |
| + "log" |
| + "net/http" |
| + "os" |
| + "strconv" |
| + "strings" |
| +) |
| + |
| +//go:embed web/pages/*.html web/static/* |
| +var webFS embed.FS |
| + |
| +type Server struct { |
| + db *DB |
| + hub *Hub |
| + pages map[string][]byte |
| +} |
| + |
| +func main() { |
| + addr := flag.String("addr", ":1747", "listen address") |
| + dbPath := flag.String("db", "lchat.db", "path to SQLite database file") |
| + resetAdmin := flag.Bool("reset-admin", false, "generate a new admin token and print its link") |
| + flag.Parse() |
| + |
| + db, err := openDB(*dbPath) |
| + if err != nil { |
| + log.Fatalf("open db: %v", err) |
| + } |
| + |
| + // Admin token: ADMIN_TOKEN env wins; otherwise generate one on first run. |
| + if tok, err := db.ensureAdminToken(os.Getenv("ADMIN_TOKEN")); err != nil { |
| + log.Fatalf("admin token: %v", err) |
| + } else if tok != "" { |
| + printAdminLink(*addr, tok) |
| + } |
| + if *resetAdmin { |
| + tok, err := db.resetAdminToken() |
| + if err != nil { |
| + log.Fatalf("reset admin token: %v", err) |
| + } |
| + printAdminLink(*addr, tok) |
| + } |
| + |
| + pages, err := loadPages() |
| + if err != nil { |
| + log.Fatalf("load pages: %v", err) |
| + } |
| + hub := newHub() |
| + go hub.run() |
| + s := &Server{db: db, hub: hub, pages: pages} |
| + |
| + staticFS, err := fs.Sub(webFS, "web/static") |
| + if err != nil { |
| + log.Fatalf("static fs: %v", err) |
| + } |
| + |
| + mux := http.NewServeMux() |
| + // Pages |
| + mux.HandleFunc("GET /", s.servePage("index.html")) |
| + mux.HandleFunc("GET /admin", s.servePage("admin.html")) |
| + mux.HandleFunc("GET /join/{token}", s.servePage("join.html")) |
| + mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticFS)))) |
| + |
| + // Chat API (transport auth via invite token; never sees plaintext) |
| + mux.HandleFunc("GET /api/messages", s.getMessages) |
| + mux.HandleFunc("GET /ws", s.handleWS) |
| + |
| + // Invite lifecycle (used by the join page) |
| + mux.HandleFunc("GET /api/invite/{token}", s.getInvite) |
| + mux.HandleFunc("POST /api/invite/{token}/claim", s.claimInvite) |
| + |
| + // Admin API (requires admin token) |
| + mux.HandleFunc("POST /api/admin/invites", s.createInvite) |
| + mux.HandleFunc("GET /api/admin/invites", s.listInvites) |
| + mux.HandleFunc("POST /api/admin/invites/{token}/revoke", s.revokeInvite) |
| + |
| + log.Printf("lchat listening on %s (db: %s)", *addr, *dbPath) |
| + if err := http.ListenAndServe(*addr, mux); err != nil { |
| + log.Fatal(err) |
| + } |
| +} |
| + |
| +// ---- pages -------------------------------------------------------------- |
| + |
| +func loadPages() (map[string][]byte, error) { |
| + pages := map[string][]byte{} |
| + for _, name := range []string{"index.html", "admin.html", "join.html"} { |
| + b, err := webFS.ReadFile("web/pages/" + name) |
| + if err != nil { |
| + return nil, err |
| + } |
| + pages[name] = b |
| + } |
| + return pages, nil |
| +} |
| + |
| +func (s *Server) servePage(name string) http.HandlerFunc { |
| + return func(w http.ResponseWriter, r *http.Request) { |
| + // "GET /" matches everything; only the exact root serves the chat page. |
| + if name == "index.html" && r.URL.Path != "/" { |
| + http.NotFound(w, r) |
| + return |
| + } |
| + w.Header().Set("Content-Type", "text/html; charset=utf-8") |
| + w.Write(s.pages[name]) |
| + } |
| +} |
| + |
| +// ---- chat API ----------------------------------------------------------- |
| + |
| +func (s *Server) getMessages(w http.ResponseWriter, r *http.Request) { |
| + token := r.URL.Query().Get("token") |
| + exists, revoked, err := s.db.inviteStatus(token) |
| + if err != nil || !exists || revoked { |
| + httpError(w, http.StatusForbidden, "invalid invite") |
| + return |
| + } |
| + after, _ := strconv.ParseInt(r.URL.Query().Get("after"), 10, 64) |
| + msgs, err := s.db.messagesAfter(after, 200) |
| + if err != nil { |
| + httpError(w, http.StatusInternalServerError, "db error") |
| + return |
| + } |
| + writeJSON(w, http.StatusOK, map[string]any{"messages": msgs}) |
| +} |
| + |
| +// ---- invite (join flow) ------------------------------------------------- |
| + |
| +func (s *Server) getInvite(w http.ResponseWriter, r *http.Request) { |
| + token := r.PathValue("token") |
| + exists, revoked, err := s.db.inviteStatus(token) |
| + if err != nil { |
| + httpError(w, http.StatusInternalServerError, "db error") |
| + return |
| + } |
| + writeJSON(w, http.StatusOK, map[string]any{"ok": exists && !revoked, "revoked": revoked}) |
| +} |
| + |
| +func (s *Server) claimInvite(w http.ResponseWriter, r *http.Request) { |
| + token := r.PathValue("token") |
| + exists, revoked, err := s.db.inviteStatus(token) |
| + if err != nil { |
| + httpError(w, http.StatusInternalServerError, "db error") |
| + return |
| + } |
| + if !exists || revoked { |
| + httpError(w, http.StatusForbidden, "invalid invite") |
| + return |
| + } |
| + if err := s.db.claimInvite(token); err != nil { |
| + httpError(w, http.StatusInternalServerError, "db error") |
| + return |
| + } |
| + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) |
| +} |
| + |
| +// ---- admin API ---------------------------------------------------------- |
| + |
| +func (s *Server) requireAdmin(w http.ResponseWriter, r *http.Request) bool { |
| + const p = "Bearer " |
| + h := r.Header.Get("Authorization") |
| + if strings.HasPrefix(h, p) && s.db.checkAdmin(strings.TrimPrefix(h, p)) { |
| + return true |
| + } |
| + httpError(w, http.StatusUnauthorized, "admin auth required") |
| + return false |
| +} |
| + |
| +func (s *Server) createInvite(w http.ResponseWriter, r *http.Request) { |
| + if !s.requireAdmin(w, r) { |
| + return |
| + } |
| + var body struct { |
| + Label string `json:"label"` |
| + } |
| + json.NewDecoder(r.Body).Decode(&body) |
| + inv, err := s.db.createInvite(strings.TrimSpace(body.Label)) |
| + if err != nil { |
| + httpError(w, http.StatusInternalServerError, "db error") |
| + return |
| + } |
| + writeJSON(w, http.StatusOK, inv) |
| +} |
| + |
| +func (s *Server) listInvites(w http.ResponseWriter, r *http.Request) { |
| + if !s.requireAdmin(w, r) { |
| + return |
| + } |
| + invites, err := s.db.listInvites() |
| + if err != nil { |
| + httpError(w, http.StatusInternalServerError, "db error") |
| + return |
| + } |
| + writeJSON(w, http.StatusOK, map[string]any{"invites": invites}) |
| +} |
| + |
| +func (s *Server) revokeInvite(w http.ResponseWriter, r *http.Request) { |
| + if !s.requireAdmin(w, r) { |
| + return |
| + } |
| + if err := s.db.revokeInvite(r.PathValue("token")); err != nil { |
| + httpError(w, http.StatusInternalServerError, "db error") |
| + return |
| + } |
| + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) |
| +} |
| + |
| +// ---- helpers ------------------------------------------------------------ |
| + |
| +func writeJSON(w http.ResponseWriter, status int, v any) { |
| + w.Header().Set("Content-Type", "application/json") |
| + w.WriteHeader(status) |
| + json.NewEncoder(w).Encode(v) |
| +} |
| + |
| +func httpError(w http.ResponseWriter, status int, msg string) { |
| + writeJSON(w, status, map[string]any{"error": msg}) |
| +} |
| + |
| +func printAdminLink(addr, token string) { |
| + host := addr |
| + if strings.HasPrefix(host, ":") { |
| + host = "localhost" + host |
| + } |
| + log.Printf("\n\n Admin link (open once, keep it secret):\n http://%s/admin#token=%s\n\n", host, token) |
| +} |
diff --git a/web/pages/admin.html b/web/pages/admin.html| new file mode 100644 |
| index 0000000..f1500a4 |
| --- /dev/null |
| +++ b/web/pages/admin.html |
| @@ -0,0 +1,49 @@ |
| +<meta charset="utf-8" /> |
| +<meta name="viewport" content="width=device-width, initial-scale=1" /> |
| +<title>lchat · admin</title> |
| +<link rel="stylesheet" href="/static/style.css" /> |
| + |
| +<div class="admin" id="admin"> |
| + <h1>lchat admin</h1> |
| + <p class="sub">Create secret invite links. Each one lets someone pick a screen name and join the chat.</p> |
| + |
| + <div id="locked" class="card hidden"> |
| + <h1>Admin link required</h1> |
| + <p>Open the admin link that was printed when you started the server. It contains your admin token.</p> |
| + </div> |
| + |
| + <div id="console" class="hidden"> |
| + <section> |
| + <h2>The chat's encryption key</h2> |
| + <p class="sub" style="margin:0 0 8px"> |
| + Every invite carries this key. The server never sees it. If you lose it, the |
| + whole chat history becomes unreadable — so keep a copy somewhere safe. |
| + </p> |
| + <div class="key-box mono" id="keyText"></div> |
| + <div class="row"> |
| + <button class="secondary" id="copyKey">Copy key</button> |
| + <button class="secondary" id="importKeyBtn">Use a different key…</button> |
| + </div> |
| + <div id="importArea" class="hidden" style="margin-top:12px"> |
| + <input id="importKeyInput" placeholder="Paste a key to use instead" class="mono" /> |
| + <div class="row"><button id="applyKey">Apply key</button></div> |
| + </div> |
| + </section> |
| + |
| + <section> |
| + <h2>Create an invite</h2> |
| + <form id="createForm" autocomplete="off"> |
| + <input id="label" placeholder="Who's this for? (e.g. Sophie) — optional" maxlength="60" /> |
| + <div class="row"><button type="submit">Create invite link</button></div> |
| + </form> |
| + </section> |
| + |
| + <section> |
| + <h2>Invites</h2> |
| + <div id="invites"><p class="sub">Loading…</p></div> |
| + </section> |
| + </div> |
| +</div> |
| +<div id="toast" class="toast"></div> |
| + |
| +<script type="module" src="/static/admin.js"></script> |
diff --git a/web/pages/index.html b/web/pages/index.html| new file mode 100644 |
| index 0000000..2fb1ec0 |
| --- /dev/null |
| +++ b/web/pages/index.html |
| @@ -0,0 +1,36 @@ |
| +<meta charset="utf-8" /> |
| +<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1" /> |
| +<title>lchat</title> |
| +<link rel="stylesheet" href="/static/style.css" /> |
| + |
| +<div id="app"></div> |
| +<div id="toast" class="toast"></div> |
| + |
| +<template id="chat-tpl"> |
| + <div class="chat"> |
| + <header> |
| + <div> |
| + <div class="title">Chat</div> |
| + <div class="who"></div> |
| + </div> |
| + <button class="secondary" id="leave">Leave</button> |
| + </header> |
| + <div class="banner hidden" id="banner"></div> |
| + <div id="feed"></div> |
| + <form class="composer" id="composer" autocomplete="off"> |
| + <input id="text" placeholder="Message" autocomplete="off" /> |
| + <button type="submit">Send</button> |
| + </form> |
| + </div> |
| +</template> |
| + |
| +<template id="noinvite-tpl"> |
| + <div class="center"> |
| + <div class="card"> |
| + <h1>You need an invite link</h1> |
| + <p>Ask whoever runs this chat for your secret link. Opening it lets you pick a screen name and join.</p> |
| + </div> |
| + </div> |
| +</template> |
| + |
| +<script type="module" src="/static/chat.js"></script> |
diff --git a/web/pages/join.html b/web/pages/join.html| new file mode 100644 |
| index 0000000..4e8a408 |
| --- /dev/null |
| +++ b/web/pages/join.html |
| @@ -0,0 +1,28 @@ |
| +<meta charset="utf-8" /> |
| +<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1" /> |
| +<title>Join the chat</title> |
| +<link rel="stylesheet" href="/static/style.css" /> |
| + |
| +<div class="center"> |
| + <div class="card" id="card"> |
| + <h1>Loading…</h1> |
| + </div> |
| +</div> |
| + |
| +<template id="form-tpl"> |
| + <h1>Pick a screen name</h1> |
| + <p>This is what everyone in the chat will see. You can use your first name or a nickname.</p> |
| + <form id="join-form" autocomplete="off"> |
| + <input id="name" placeholder="e.g. Sophie" maxlength="32" autocomplete="off" /> |
| + <div class="row"> |
| + <button type="submit">Join the chat</button> |
| + </div> |
| + </form> |
| +</template> |
| + |
| +<template id="error-tpl"> |
| + <h1>This link doesn't work</h1> |
| + <p id="error-msg">This invite link is invalid or has been turned off. Ask for a new one.</p> |
| +</template> |
| + |
| +<script type="module" src="/static/join.js"></script> |
diff --git a/web/static/admin.js b/web/static/admin.js| new file mode 100644 |
| index 0000000..a892ad5 |
| --- /dev/null |
| +++ b/web/static/admin.js |
| @@ -0,0 +1,176 @@ |
| +import { generateKey, importKey } from "/static/crypto.js"; |
| + |
| +const ADMIN_KEY = "lchat_admin_token"; |
| +const GROUP_KEY = "lchat_group_key"; |
| + |
| +// Admin token comes from the #fragment so it isn't sent on the page load. |
| +const hash = new URLSearchParams(location.hash.slice(1)); |
| +const fragToken = hash.get("token"); |
| +if (fragToken) { |
| + localStorage.setItem(ADMIN_KEY, fragToken); |
| + history.replaceState(null, "", location.pathname); // scrub it from the URL bar |
| +} |
| +const adminToken = localStorage.getItem(ADMIN_KEY); |
| + |
| +const locked = document.getElementById("locked"); |
| +const console_ = document.getElementById("console"); |
| + |
| +init(); |
| + |
| +async function init() { |
| + if (!adminToken) return show(locked); |
| + |
| + // Verify the token by trying to list invites. |
| + const res = await api("GET", "/api/admin/invites"); |
| + if (!res.ok) { |
| + localStorage.removeItem(ADMIN_KEY); |
| + return show(locked); |
| + } |
| + show(console_); |
| + |
| + await ensureGroupKey(); |
| + wireKeyControls(); |
| + wireCreate(); |
| + renderInvites((await res.json()).invites || []); |
| +} |
| + |
| +// ---- group key ---------------------------------------------------------- |
| + |
| +async function ensureGroupKey() { |
| + if (!localStorage.getItem(GROUP_KEY)) { |
| + const { b64 } = await generateKey(); |
| + localStorage.setItem(GROUP_KEY, b64); |
| + } |
| + document.getElementById("keyText").textContent = localStorage.getItem(GROUP_KEY); |
| +} |
| + |
| +function wireKeyControls() { |
| + document.getElementById("copyKey").onclick = () => |
| + copy(localStorage.getItem(GROUP_KEY), "Key copied"); |
| + |
| + const importArea = document.getElementById("importArea"); |
| + document.getElementById("importKeyBtn").onclick = () => |
| + importArea.classList.toggle("hidden"); |
| + |
| + document.getElementById("applyKey").onclick = async () => { |
| + const v = document.getElementById("importKeyInput").value.trim(); |
| + if (!v) return; |
| + try { |
| + await importKey(v); // validate |
| + } catch { |
| + return toast("That doesn't look like a valid key"); |
| + } |
| + localStorage.setItem(GROUP_KEY, v); |
| + document.getElementById("keyText").textContent = v; |
| + importArea.classList.add("hidden"); |
| + toast("Key updated — new invites will use it"); |
| + }; |
| +} |
| + |
| +// ---- invites ------------------------------------------------------------ |
| + |
| +function wireCreate() { |
| + document.getElementById("createForm").onsubmit = async (e) => { |
| + e.preventDefault(); |
| + const label = document.getElementById("label").value.trim(); |
| + const res = await api("POST", "/api/admin/invites", { label }); |
| + if (!res.ok) return toast("Couldn't create invite"); |
| + document.getElementById("label").value = ""; |
| + await refresh(); |
| + const inv = await res.json(); |
| + copy(linkFor(inv.token), "Invite link copied"); |
| + }; |
| +} |
| + |
| +async function refresh() { |
| + const res = await api("GET", "/api/admin/invites"); |
| + if (res.ok) renderInvites((await res.json()).invites || []); |
| +} |
| + |
| +function linkFor(token) { |
| + const key = localStorage.getItem(GROUP_KEY); |
| + return `${location.origin}/join/${token}#k=${key}`; |
| +} |
| + |
| +function renderInvites(invites) { |
| + const box = document.getElementById("invites"); |
| + box.innerHTML = ""; |
| + if (!invites.length) { |
| + box.innerHTML = `<p class="sub">No invites yet. Create one above.</p>`; |
| + return; |
| + } |
| + for (const inv of invites) { |
| + const row = document.createElement("div"); |
| + row.className = "invite"; |
| + |
| + const meta = document.createElement("div"); |
| + meta.className = "meta"; |
| + const label = document.createElement("div"); |
| + label.className = "label"; |
| + label.textContent = inv.label || "(no label)"; |
| + const status = document.createElement("div"); |
| + status.className = "status" + (inv.revoked ? " revoked" : ""); |
| + status.textContent = inv.revoked |
| + ? "revoked" |
| + : inv.claimed_at |
| + ? "joined " + new Date(inv.claimed_at * 1000).toLocaleDateString() |
| + : "not used yet"; |
| + meta.appendChild(label); |
| + meta.appendChild(status); |
| + row.appendChild(meta); |
| + |
| + if (!inv.revoked) { |
| + const copyBtn = document.createElement("button"); |
| + copyBtn.className = "secondary"; |
| + copyBtn.textContent = "Copy link"; |
| + copyBtn.onclick = () => copy(linkFor(inv.token), "Invite link copied"); |
| + row.appendChild(copyBtn); |
| + |
| + const revokeBtn = document.createElement("button"); |
| + revokeBtn.className = "secondary"; |
| + revokeBtn.textContent = "Revoke"; |
| + revokeBtn.onclick = async () => { |
| + if (!confirm("Revoke this invite? They'll lose access to the server.")) return; |
| + await api("POST", `/api/admin/invites/${inv.token}/revoke`); |
| + refresh(); |
| + }; |
| + row.appendChild(revokeBtn); |
| + } |
| + box.appendChild(row); |
| + } |
| +} |
| + |
| +// ---- helpers ------------------------------------------------------------ |
| + |
| +function api(method, path, body) { |
| + return fetch(path, { |
| + method, |
| + headers: { |
| + Authorization: "Bearer " + adminToken, |
| + ...(body ? { "Content-Type": "application/json" } : {}), |
| + }, |
| + body: body ? JSON.stringify(body) : undefined, |
| + }); |
| +} |
| + |
| +function show(el) { |
| + el.classList.remove("hidden"); |
| +} |
| + |
| +async function copy(text, msg) { |
| + try { |
| + await navigator.clipboard.writeText(text); |
| + toast(msg); |
| + } catch { |
| + prompt("Copy this:", text); |
| + } |
| +} |
| + |
| +let toastTimer; |
| +function toast(msg) { |
| + const t = document.getElementById("toast"); |
| + t.textContent = msg; |
| + t.classList.add("show"); |
| + clearTimeout(toastTimer); |
| + toastTimer = setTimeout(() => t.classList.remove("show"), 1800); |
| +} |
diff --git a/web/static/chat.js b/web/static/chat.js| new file mode 100644 |
| index 0000000..fd564f7 |
| --- /dev/null |
| +++ b/web/static/chat.js |
| @@ -0,0 +1,142 @@ |
| +import { importKey, encrypt, decrypt } from "/static/crypto.js"; |
| + |
| +const SESSION_KEY = "lchat_session"; |
| + |
| +const app = document.getElementById("app"); |
| +const session = loadSession(); |
| + |
| +if (!session) { |
| + app.appendChild(tpl("noinvite-tpl")); |
| +} else { |
| + start(session).catch((err) => { |
| + console.error(err); |
| + showBanner("Something went wrong loading the chat.", true); |
| + }); |
| +} |
| + |
| +function loadSession() { |
| + try { |
| + const s = JSON.parse(localStorage.getItem(SESSION_KEY) || "null"); |
| + if (s && s.token && s.key && s.name) return s; |
| + } catch {} |
| + return null; |
| +} |
| + |
| +function tpl(id) { |
| + return document.getElementById(id).content.cloneNode(true); |
| +} |
| + |
| +async function start(session) { |
| + app.appendChild(tpl("chat-tpl")); |
| + |
| + const key = await importKey(session.key); |
| + const feed = document.getElementById("feed"); |
| + const who = document.querySelector(".who"); |
| + who.textContent = `You are ${session.name}`; |
| + |
| + document.getElementById("leave").onclick = () => { |
| + if (confirm("Leave this chat on this device? You'll need your link to rejoin.")) { |
| + localStorage.removeItem(SESSION_KEY); |
| + location.reload(); |
| + } |
| + }; |
| + |
| + const seen = new Set(); |
| + let lastId = 0; |
| + |
| + async function render(msg) { |
| + if (seen.has(msg.id)) return; |
| + seen.add(msg.id); |
| + if (msg.id > lastId) lastId = msg.id; |
| + let payload; |
| + try { |
| + payload = await decrypt(key, msg.blob); |
| + } catch { |
| + return; // wrong key or corrupt — skip |
| + } |
| + const mine = payload.name === session.name; |
| + const el = document.createElement("div"); |
| + el.className = "msg " + (mine ? "me" : "them"); |
| + if (!mine) { |
| + const n = document.createElement("div"); |
| + n.className = "name"; |
| + n.textContent = payload.name; |
| + el.appendChild(n); |
| + } |
| + const bubble = document.createElement("div"); |
| + bubble.className = "bubble"; |
| + bubble.textContent = payload.text; |
| + el.appendChild(bubble); |
| + const t = document.createElement("div"); |
| + t.className = "time"; |
| + t.textContent = formatTime(payload.ts || msg.created_at * 1000); |
| + el.appendChild(t); |
| + |
| + const atBottom = feed.scrollHeight - feed.scrollTop - feed.clientHeight < 80; |
| + feed.appendChild(el); |
| + if (atBottom || mine) feed.scrollTop = feed.scrollHeight; |
| + } |
| + |
| + // 1) Load recent history. |
| + const history = await fetchMessages(session.token, 0); |
| + for (const m of history) await render(m); |
| + feed.scrollTop = feed.scrollHeight; |
| + |
| + // 2) Live updates over WebSocket, with reconnect + gap fill. |
| + let ws; |
| + function connect() { |
| + const proto = location.protocol === "https:" ? "wss" : "ws"; |
| + ws = new WebSocket(`${proto}://${location.host}/ws?token=${encodeURIComponent(session.token)}`); |
| + ws.onopen = async () => { |
| + showBanner("", false); |
| + // Fill anything missed while disconnected. |
| + const missed = await fetchMessages(session.token, lastId); |
| + for (const m of missed) await render(m); |
| + }; |
| + ws.onmessage = (ev) => { |
| + try { |
| + const msg = JSON.parse(ev.data); |
| + if (msg.type === "msg") render(msg); |
| + } catch {} |
| + }; |
| + ws.onclose = () => { |
| + showBanner("Reconnecting…", false); |
| + setTimeout(connect, 1500); |
| + }; |
| + ws.onerror = () => ws.close(); |
| + } |
| + connect(); |
| + |
| + // 3) Sending. |
| + const form = document.getElementById("composer"); |
| + const input = document.getElementById("text"); |
| + form.onsubmit = async (e) => { |
| + e.preventDefault(); |
| + const text = input.value.trim(); |
| + if (!text || !ws || ws.readyState !== WebSocket.OPEN) return; |
| + input.value = ""; |
| + const blob = await encrypt(key, { name: session.name, text, ts: Date.now() }); |
| + ws.send(JSON.stringify({ type: "msg", blob })); |
| + }; |
| + input.focus(); |
| +} |
| + |
| +async function fetchMessages(token, after) { |
| + const res = await fetch(`/api/messages?token=${encodeURIComponent(token)}&after=${after}`); |
| + if (!res.ok) return []; |
| + const data = await res.json(); |
| + return data.messages || []; |
| +} |
| + |
| +function formatTime(ms) { |
| + const d = new Date(ms); |
| + return d.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" }); |
| +} |
| + |
| +function showBanner(text, warn) { |
| + const b = document.getElementById("banner"); |
| + if (!b) return; |
| + b.textContent = text; |
| + b.classList.toggle("hidden", !text); |
| + b.classList.toggle("warn", !!warn); |
| +} |
diff --git a/web/static/crypto.js b/web/static/crypto.js| new file mode 100644 |
| index 0000000..710a01f |
| --- /dev/null |
| +++ b/web/static/crypto.js |
| @@ -0,0 +1,59 @@ |
| +// All message encryption happens in the browser. The AES-GCM key is carried in |
| +// the secret link's #fragment and never sent to the server. The server only |
| +// ever stores/relays the opaque blobs produced here. |
| + |
| +export function b64urlToBytes(s) { |
| + s = s.replace(/-/g, "+").replace(/_/g, "/"); |
| + while (s.length % 4) s += "="; |
| + const bin = atob(s); |
| + const out = new Uint8Array(bin.length); |
| + for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); |
| + return out; |
| +} |
| + |
| +export function bytesToB64url(bytes) { |
| + let bin = ""; |
| + for (const b of bytes) bin += String.fromCharCode(b); |
| + return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); |
| +} |
| + |
| +export async function generateKey() { |
| + const key = await crypto.subtle.generateKey( |
| + { name: "AES-GCM", length: 256 }, |
| + true, |
| + ["encrypt", "decrypt"] |
| + ); |
| + const raw = new Uint8Array(await crypto.subtle.exportKey("raw", key)); |
| + return { key, b64: bytesToB64url(raw) }; |
| +} |
| + |
| +export async function importKey(b64) { |
| + return crypto.subtle.importKey( |
| + "raw", |
| + b64urlToBytes(b64), |
| + "AES-GCM", |
| + true, |
| + ["encrypt", "decrypt"] |
| + ); |
| +} |
| + |
| +// encrypt returns base64url( iv(12) || ciphertext ). |
| +export async function encrypt(key, obj) { |
| + const iv = crypto.getRandomValues(new Uint8Array(12)); |
| + const pt = new TextEncoder().encode(JSON.stringify(obj)); |
| + const ct = new Uint8Array( |
| + await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, pt) |
| + ); |
| + const combined = new Uint8Array(iv.length + ct.length); |
| + combined.set(iv, 0); |
| + combined.set(ct, iv.length); |
| + return bytesToB64url(combined); |
| +} |
| + |
| +export async function decrypt(key, blob) { |
| + const data = b64urlToBytes(blob); |
| + const iv = data.slice(0, 12); |
| + const ct = data.slice(12); |
| + const pt = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, ct); |
| + return JSON.parse(new TextDecoder().decode(pt)); |
| +} |
diff --git a/web/static/join.js b/web/static/join.js| new file mode 100644 |
| index 0000000..f8d4f13 |
| --- /dev/null |
| +++ b/web/static/join.js |
| @@ -0,0 +1,65 @@ |
| +// Reads the invite token from the path and the encryption key from the |
| +// #fragment (which the browser never sends to the server), then lets the user |
| +// pick a screen name and stashes everything locally. |
| +import { importKey } from "/static/crypto.js"; |
| + |
| +const SESSION_KEY = "lchat_session"; |
| +const card = document.getElementById("card"); |
| + |
| +const token = location.pathname.split("/").filter(Boolean).pop(); |
| +const hash = new URLSearchParams(location.hash.slice(1)); |
| +const key = hash.get("k"); |
| + |
| +init(); |
| + |
| +async function init() { |
| + if (!token || !key) return showError("This link is missing its secret key."); |
| + |
| + // The key must be a valid AES-GCM key before we trust the link. |
| + try { |
| + await importKey(key); |
| + } catch { |
| + return showError("This link's secret key is malformed."); |
| + } |
| + |
| + let info; |
| + try { |
| + const res = await fetch(`/api/invite/${encodeURIComponent(token)}`); |
| + info = await res.json(); |
| + } catch { |
| + return showError("Couldn't reach the server. Try again."); |
| + } |
| + if (!info.ok) { |
| + return showError( |
| + info.revoked |
| + ? "This invite has been turned off. Ask for a new one." |
| + : "This invite link is invalid. Ask for a new one." |
| + ); |
| + } |
| + |
| + showForm(); |
| +} |
| + |
| +function showForm() { |
| + card.innerHTML = ""; |
| + card.appendChild(document.getElementById("form-tpl").content.cloneNode(true)); |
| + const input = card.querySelector("#name"); |
| + input.focus(); |
| + card.querySelector("#join-form").onsubmit = async (e) => { |
| + e.preventDefault(); |
| + const name = input.value.trim(); |
| + if (!name) return; |
| + try { |
| + await fetch(`/api/invite/${encodeURIComponent(token)}/claim`, { method: "POST" }); |
| + } catch {} |
| + localStorage.setItem(SESSION_KEY, JSON.stringify({ token, key, name })); |
| + // Redirect WITHOUT the fragment so the key isn't left in the URL bar. |
| + location.href = "/"; |
| + }; |
| +} |
| + |
| +function showError(msg) { |
| + card.innerHTML = ""; |
| + card.appendChild(document.getElementById("error-tpl").content.cloneNode(true)); |
| + card.querySelector("#error-msg").textContent = msg; |
| +} |
diff --git a/web/static/style.css b/web/static/style.css| new file mode 100644 |
| index 0000000..6407f42 |
| --- /dev/null |
| +++ b/web/static/style.css |
| @@ -0,0 +1,172 @@ |
| +:root { |
| + --bg: #f6f7fb; |
| + --panel: #ffffff; |
| + --ink: #1c2333; |
| + --muted: #7a8295; |
| + --accent: #5b6cff; |
| + --accent-ink: #ffffff; |
| + --bubble-me: #5b6cff; |
| + --bubble-them: #eef0f6; |
| + --radius: 18px; |
| + --border: #e7e9f0; |
| +} |
| + |
| +* { box-sizing: border-box; } |
| + |
| +html, body { |
| + margin: 0; |
| + height: 100%; |
| + background: var(--bg); |
| + color: var(--ink); |
| + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; |
| + -webkit-font-smoothing: antialiased; |
| +} |
| + |
| +button { |
| + font: inherit; |
| + cursor: pointer; |
| + border: none; |
| + border-radius: 999px; |
| + padding: 10px 18px; |
| + background: var(--accent); |
| + color: var(--accent-ink); |
| + font-weight: 600; |
| +} |
| +button.secondary { |
| + background: var(--bubble-them); |
| + color: var(--ink); |
| +} |
| +button:disabled { opacity: 0.5; cursor: default; } |
| + |
| +input, textarea { |
| + font: inherit; |
| + border: 1px solid var(--border); |
| + border-radius: 12px; |
| + padding: 12px 14px; |
| + background: #fff; |
| + color: var(--ink); |
| + width: 100%; |
| +} |
| +input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: transparent; } |
| + |
| +/* ---- centered card layout (join / fallback) ---- */ |
| +.center { |
| + min-height: 100%; |
| + display: grid; |
| + place-items: center; |
| + padding: 24px; |
| +} |
| +.card { |
| + background: var(--panel); |
| + border: 1px solid var(--border); |
| + border-radius: 24px; |
| + padding: 28px; |
| + width: 100%; |
| + max-width: 420px; |
| + box-shadow: 0 12px 40px rgba(20, 30, 80, 0.06); |
| +} |
| +.card h1 { margin: 0 0 6px; font-size: 24px; } |
| +.card p { margin: 0 0 18px; color: var(--muted); } |
| +.card .row { display: flex; gap: 10px; margin-top: 14px; } |
| + |
| +/* ---- chat ---- */ |
| +.chat { |
| + display: flex; |
| + flex-direction: column; |
| + height: 100%; |
| + max-width: 720px; |
| + margin: 0 auto; |
| + background: var(--panel); |
| + border-left: 1px solid var(--border); |
| + border-right: 1px solid var(--border); |
| +} |
| +.chat header { |
| + padding: 14px 18px; |
| + border-bottom: 1px solid var(--border); |
| + display: flex; |
| + align-items: center; |
| + justify-content: space-between; |
| + background: var(--panel); |
| +} |
| +.chat header .title { font-weight: 700; } |
| +.chat header .who { color: var(--muted); font-size: 13px; } |
| + |
| +#feed { |
| + flex: 1; |
| + overflow-y: auto; |
| + padding: 18px; |
| + display: flex; |
| + flex-direction: column; |
| + gap: 4px; |
| +} |
| +.msg { display: flex; flex-direction: column; max-width: 78%; margin-top: 10px; } |
| +.msg .name { font-size: 12px; color: var(--muted); margin: 0 6px 3px; } |
| +.msg .bubble { |
| + padding: 9px 13px; |
| + border-radius: var(--radius); |
| + background: var(--bubble-them); |
| + line-height: 1.35; |
| + word-wrap: break-word; |
| + white-space: pre-wrap; |
| +} |
| +.msg.me { align-self: flex-end; align-items: flex-end; } |
| +.msg.me .bubble { background: var(--bubble-me); color: #fff; border-bottom-right-radius: 6px; } |
| +.msg.them .bubble { border-bottom-left-radius: 6px; } |
| +.msg .time { font-size: 11px; color: var(--muted); margin: 3px 6px 0; } |
| + |
| +.composer { |
| + display: flex; |
| + gap: 10px; |
| + padding: 14px; |
| + border-top: 1px solid var(--border); |
| + background: var(--panel); |
| +} |
| +.composer input { border-radius: 999px; } |
| + |
| +.banner { |
| + text-align: center; |
| + font-size: 13px; |
| + color: var(--muted); |
| + padding: 6px; |
| +} |
| +.banner.warn { color: #b4530a; } |
| + |
| +/* ---- admin ---- */ |
| +.admin { max-width: 760px; margin: 0 auto; padding: 24px; } |
| +.admin h1 { margin: 0 0 4px; } |
| +.admin .sub { color: var(--muted); margin: 0 0 24px; } |
| +.admin section { |
| + background: var(--panel); |
| + border: 1px solid var(--border); |
| + border-radius: 18px; |
| + padding: 18px; |
| + margin-bottom: 18px; |
| +} |
| +.admin section h2 { margin: 0 0 12px; font-size: 16px; } |
| +.invite { |
| + display: flex; |
| + align-items: center; |
| + gap: 10px; |
| + padding: 12px 0; |
| + border-top: 1px solid var(--border); |
| +} |
| +.invite:first-of-type { border-top: none; } |
| +.invite .meta { flex: 1; min-width: 0; } |
| +.invite .meta .label { font-weight: 600; } |
| +.invite .meta .status { font-size: 12px; color: var(--muted); } |
| +.invite .status.revoked { color: #c2410c; } |
| +.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; } |
| +.key-box { |
| + background: var(--bubble-them); |
| + border-radius: 12px; |
| + padding: 12px; |
| + word-break: break-all; |
| + margin: 10px 0; |
| +} |
| +.hidden { display: none !important; } |
| +.toast { |
| + position: fixed; left: 50%; bottom: 24px; transform: translateX(-50%); |
| + background: var(--ink); color: #fff; padding: 10px 16px; border-radius: 999px; |
| + font-size: 14px; opacity: 0; transition: opacity .2s; pointer-events: none; |
| +} |
| +.toast.show { opacity: 1; } |