eleven / messenger Public

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>
Paul Campbell cce8475206e0f1405297cb5f34f18e80da9e724a
15 files changed, +1534 −0
  • .gitignore +15 −0
  • README.md +85 −0
  • db.go +263 −0
  • go.mod +24 −0
  • go.sum +51 −0
  • hub.go +138 −0
  • main.go +231 −0
  • web/pages/admin.html +49 −0
  • web/pages/index.html +36 −0
  • web/pages/join.html +28 −0
  • web/static/admin.js +176 −0
  • web/static/chat.js +142 −0
  • web/static/crypto.js +59 −0
  • web/static/join.js +65 −0
  • web/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; }