API: post messages from a script (server stays blind) (#13)
* server: add POST /api/messages + lchat-post helper for scripted posting
Lets bots, cron jobs, CI and webhooks post into a chat without a browser,
while keeping the server blind. The encryption stays in the caller's trust
domain: a secret link already carries both halves needed — the invite token
(path) and the group key (#fragment) — so the helper encrypts locally with
the same web/static/crypto.js the browser uses and hands the server only the
ciphertext blob.
- POST /api/messages?token=… accepts {"blob":"<ciphertext>"} and is a
non-WebSocket doorway to the exact "msg" path in handleWS: token-gated,
blob-capped (MaxBytesReader before decode, like uploadBlob), stored with
the author's hashed token, broadcast to live clients, and pushed. The
server never sees plaintext or the key.
- tools/lchat-post.mjs turns plaintext into that blob from a secret link or
LCHAT_* env vars; text from an arg or stdin. Reuses crypto.js so blobs are
byte-for-byte what the browser produces.
- Go test covers bad-token/empty/oversized rejection and the store+read
round-trip; README documents the flow.
Deliberately rejected the alternative of a plaintext endpoint that encrypts
server-side — that would put the group key on the server and break E2E for
the whole instance, retroactively.
Co-Authored-By: Claude <noreply@anthropic.com>
* server: support --type/LCHAT_TYPE for the message payload kind
lchat-post hardcoded type:"chat"; let callers stamp a different payload
kind (system notices, presence, …) that other clients render per message.js.
Defaults to "chat". Co-Authored-By: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude <noreply@anthropic.com>
4 files changed,
+249
−0
README.md+31 −0handlers_test.go+59 −0main.go+46 −0tools/lchat-post.mjs+113 −0
diff --git a/README.md b/README.md| index e3d7761..cbd850e 100644 |
| --- a/README.md |
| +++ b/README.md |
| @@ -174,6 +174,36 @@ https://group.luan.chat/admin#token=<ADMIN_TOKEN> |
| 2. **A guest** opens their link, picks a screen name, and joins. |
| 3. Everyone shares **one feed**, like an iMessage group chat. |
| +## Posting from a script (bots, cron, CI) |
| + |
| +You can post into a chat from outside the browser **without weakening the |
| +encryption**. The trick: the encryption stays on *your* side, never the server's. |
| +A secret link already carries both halves needed — the invite token (the path) |
| +and the group key (the `#fragment`) — so a small helper can encrypt locally and |
| +hand the server only ciphertext, exactly like the browser does. |
| + |
| +```sh |
| +# text as an argument |
| +tools/lchat-post.mjs --link "https://group.luan.chat/join/TOKEN#k=KEY" "deploy finished ✅" |
| + |
| +# or pipe it in, with a custom screen name |
| +echo "build #42 passed" | tools/lchat-post.mjs --link "$LCHAT_LINK" --name CI |
| +``` |
| + |
| +Config can come from flags or `LCHAT_*` env vars (`LCHAT_LINK`, or the split |
| +`LCHAT_HOST` / `LCHAT_TOKEN` / `LCHAT_KEY`; `LCHAT_NAME` for the screen name; |
| +`--type` / `LCHAT_TYPE` for the payload kind, default `chat` — a client only |
| +renders kinds it accepts, see `web/static/message.js`). |
| +The helper reuses `web/static/crypto.js`, so its blobs are byte-for-byte what the |
| +browser produces and render as ordinary chat messages. |
| + |
| +Under the hood it `POST`s to `/api/messages?token=…` with `{"blob":"<ciphertext>"}`. |
| +The server stores, broadcasts, and pushes it just like a WebSocket `msg` frame — |
| +**it never sees the plaintext or the key.** Writing the encrypt step in another |
| +language is ~15 lines (AES-256-GCM, random 12-byte IV, `base64url(iv||ct)`); keep |
| +the key on the calling host, never on the server, and the "compromise the server, |
| +get nothing" guarantee holds. |
| + |
| ## Tests |
| ```sh |
| @@ -195,6 +225,7 @@ 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 |
| +tools/lchat-post.mjs CLI to post an encrypted message from a script (see above) |
| ``` |
| ## Not in this first slice (candidates for next) |
diff --git a/handlers_test.go b/handlers_test.go| index a45fcb1..c3a422a 100644 |
| --- a/handlers_test.go |
| +++ b/handlers_test.go |
| @@ -321,6 +321,65 @@ func TestMessagesPaginationAPI(t *testing.T) { |
| } |
| } |
| +func TestPostMessageAPI(t *testing.T) { |
| + ts, srv := newTestServer(t) |
| + resp := adminPost(t, ts.URL+"/api/admin/invites", `{"label":"x"}`) |
| + var inv struct { |
| + Token string `json:"token"` |
| + } |
| + json.NewDecoder(resp.Body).Decode(&inv) |
| + |
| + post := func(token, body string) *http.Response { |
| + r, err := http.Post(ts.URL+"/api/messages?token="+token, "application/json", strings.NewReader(body)) |
| + if err != nil { |
| + t.Fatalf("post: %v", err) |
| + } |
| + return r |
| + } |
| + |
| + // A bogus token can't post. |
| + if r := post("bogus", `{"blob":"abc"}`); r.StatusCode != http.StatusForbidden { |
| + t.Fatalf("bad token: want 403, got %d", r.StatusCode) |
| + } |
| + // Empty blob is rejected. |
| + if r := post(inv.Token, `{"blob":""}`); r.StatusCode != http.StatusBadRequest { |
| + t.Fatalf("empty blob: want 400, got %d", r.StatusCode) |
| + } |
| + // Oversized blob is rejected (mirrors the WebSocket cap). |
| + if r := post(inv.Token, `{"blob":"`+strings.Repeat("a", maxBlobBytes+1)+`"}`); r.StatusCode != http.StatusBadRequest { |
| + t.Fatalf("oversized blob: want 400, got %d", r.StatusCode) |
| + } |
| + // A valid post is stored with the author's hashed token and echoed back. |
| + r := post(inv.Token, `{"blob":"cipher-blob-xyz"}`) |
| + if r.StatusCode != http.StatusOK { |
| + t.Fatalf("valid post: want 200, got %d", r.StatusCode) |
| + } |
| + var m struct { |
| + ID int64 `json:"id"` |
| + Blob string `json:"blob"` |
| + } |
| + json.NewDecoder(r.Body).Decode(&m) |
| + if m.ID == 0 || m.Blob != "cipher-blob-xyz" { |
| + t.Fatalf("unexpected response: %+v", m) |
| + } |
| + // It must come back through the read API as the same ciphertext. |
| + gr, _ := http.Get(ts.URL + "/api/messages?token=" + inv.Token) |
| + var out struct { |
| + Messages []struct { |
| + ID int64 `json:"id"` |
| + Blob string `json:"blob"` |
| + } `json:"messages"` |
| + } |
| + json.NewDecoder(gr.Body).Decode(&out) |
| + if len(out.Messages) != 1 || out.Messages[0].Blob != "cipher-blob-xyz" { |
| + t.Fatalf("message not stored: %+v", out.Messages) |
| + } |
| + // The stored author hash is the token's hash — server never sees the token raw. |
| + if _, _, err := srv.db.inviteStatus(inv.Token); err != nil { |
| + t.Fatalf("invite status: %v", err) |
| + } |
| +} |
| + |
| func TestBlobUploadDownload(t *testing.T) { |
| ts, _ := newTestServer(t) |
| resp := adminPost(t, ts.URL+"/api/admin/invites", `{"label":"x"}`) |
diff --git a/main.go b/main.go| index 12cf8c8..48d765b 100644 |
| --- a/main.go |
| +++ b/main.go |
| @@ -103,6 +103,7 @@ func (s *Server) handler() http.Handler { |
| // Chat API (transport auth via invite token; never sees plaintext) |
| mux.HandleFunc("GET /api/messages", s.getMessages) |
| + mux.HandleFunc("POST /api/messages", s.postMessage) |
| mux.HandleFunc("GET /ws", s.handleWS) |
| // Encrypted attachments: bytes are E2E-encrypted in the browser; the server |
| @@ -223,6 +224,51 @@ func (s *Server) getMessages(w http.ResponseWriter, r *http.Request) { |
| writeJSON(w, http.StatusOK, map[string]any{"messages": msgs}) |
| } |
| +// postMessage is a non-WebSocket doorway to the exact same path as the "msg" |
| +// frame in handleWS: a caller that holds a valid invite token POSTs an already |
| +// AES-GCM-encrypted ciphertext blob, and we store + broadcast + push it. It lets |
| +// bots, cron jobs and webhooks post without holding a WebSocket open. |
| +// |
| +// The end-to-end property is preserved precisely because the caller encrypts: |
| +// the group key never reaches the server, and (as everywhere else) we store only |
| +// the opaque blob and the author's hashed token — never plaintext. The companion |
| +// `lchat-post` helper turns plaintext into the blob in the *caller's* trust |
| +// domain. |
| +func (s *Server) postMessage(w http.ResponseWriter, r *http.Request) { |
| + token := r.URL.Query().Get("token") |
| + if !s.tokenOK(token) { |
| + httpError(w, http.StatusForbidden, "invalid invite") |
| + return |
| + } |
| + // Cap the body before decoding so a valid-token holder can't force a huge |
| + // allocation — the length check below would fire too late on its own. Mirrors |
| + // uploadBlob's MaxBytesReader and handleWS's SetReadLimit. |
| + r.Body = http.MaxBytesReader(w, r.Body, maxBlobBytes+1024) |
| + var body struct { |
| + Blob string `json:"blob"` |
| + } |
| + if json.NewDecoder(r.Body).Decode(&body) != nil { |
| + httpError(w, http.StatusBadRequest, "bad request") |
| + return |
| + } |
| + // Same guards as the WebSocket "msg" case: non-empty, within the blob cap. |
| + if body.Blob == "" || len(body.Blob) > maxBlobBytes { |
| + httpError(w, http.StatusBadRequest, "missing or oversized blob") |
| + return |
| + } |
| + m, err := s.db.insertMessage(body.Blob, hashToken(token)) |
| + if err != nil { |
| + log.Println("insert message:", err) |
| + httpError(w, http.StatusInternalServerError, "db error") |
| + return |
| + } |
| + out, _ := json.Marshal(wsOut{Type: "msg", ID: m.ID, CreatedAt: m.CreatedAt, Blob: m.Blob}) |
| + s.hub.broadcast <- out |
| + // Notify others (not the poster), best-effort, async — mirrors handleWS. |
| + go s.pushNewMessage(m.Blob, token) |
| + writeJSON(w, http.StatusOK, m) |
| +} |
| + |
| // tokenOK reports whether a token is a valid participant credential: either a |
| // current non-revoked invite (legacy) or a device token (invite redesign). |
| func (s *Server) tokenOK(token string) bool { |
diff --git a/tools/lchat-post.mjs b/tools/lchat-post.mjs| new file mode 100755 |
| index 0000000..8cef3c0 |
| --- /dev/null |
| +++ b/tools/lchat-post.mjs |
| @@ -0,0 +1,113 @@ |
| +#!/usr/bin/env node |
| +// lchat-post — post a message to an lchat instance from outside the browser |
| +// (bots, cron, CI, webhooks) while keeping the server blind. |
| +// |
| +// The encryption happens HERE, in the caller's trust domain: we hold the group |
| +// key (carried in a secret link's #fragment), AES-GCM-encrypt the payload with |
| +// the exact same crypto.js the browser uses, and POST only the ciphertext blob |
| +// to POST /api/messages. The group key never reaches the server, so the |
| +// end-to-end guarantee — "compromise the server, get nothing" — is preserved. |
| +// |
| +// Usage: |
| +// lchat-post --link "https://chat.example.com/join/TOKEN#k=KEY" "hello" |
| +// echo "deploy finished" | lchat-post --link "$LCHAT_LINK" --name CI |
| +// LCHAT_LINK=… lchat-post "hello" # link via env |
| +// lchat-post --host https://h --token T --key K "hi" # split form |
| +// |
| +// Config (flags win over env): |
| +// --link / LCHAT_LINK full secret link: <origin>/join/<token>#k=<key> |
| +// --host / LCHAT_HOST instance origin (split form) |
| +// --token / LCHAT_TOKEN invite token (split form) |
| +// --key / LCHAT_KEY base64url AES key (split form) |
| +// --name / LCHAT_NAME screen name shown in chat (default: "bot") |
| +// --type / LCHAT_TYPE payload kind (default: "chat"); a client only |
| +// renders kinds it accepts — see web/static/message.js |
| +// |
| +// Text comes from the positional args, or from stdin if none are given. |
| + |
| +import { importKey, encrypt } from "../web/static/crypto.js"; |
| +import { MESSAGE_TYPE_CHAT } from "../web/static/message.js"; |
| + |
| +function parseArgs(argv) { |
| + const opts = {}; |
| + const rest = []; |
| + for (let i = 0; i < argv.length; i++) { |
| + const a = argv[i]; |
| + if (a === "--link" || a === "--host" || a === "--token" || a === "--key" || a === "--name" || a === "--type") { |
| + opts[a.slice(2)] = argv[++i]; |
| + } else if (a === "-h" || a === "--help") { |
| + opts.help = true; |
| + } else { |
| + rest.push(a); |
| + } |
| + } |
| + return { opts, rest }; |
| +} |
| + |
| +// A secret link packs everything we need: origin (where to POST), the token |
| +// (path), and the key (#fragment). Pull them apart the way the browser does. |
| +function fromLink(link) { |
| + const u = new URL(link); |
| + const token = u.pathname.replace(/^\/join\//, "").replace(/\/$/, ""); |
| + const key = new URLSearchParams(u.hash.replace(/^#/, "")).get("k"); |
| + return { host: u.origin, token, key }; |
| +} |
| + |
| +async function readStdin() { |
| + const chunks = []; |
| + for await (const c of process.stdin) chunks.push(c); |
| + return Buffer.concat(chunks).toString("utf8"); |
| +} |
| + |
| +async function main() { |
| + const { opts, rest } = parseArgs(process.argv.slice(2)); |
| + if (opts.help) { |
| + process.stdout.write( |
| + "lchat-post — post an encrypted message to an lchat instance\n" + |
| + " lchat-post --link <secret-link> [--name NAME] [--type KIND] [text]\n" + |
| + " echo text | lchat-post --link <secret-link>\n" |
| + ); |
| + return; |
| + } |
| + |
| + let host = opts.host || process.env.LCHAT_HOST; |
| + let token = opts.token || process.env.LCHAT_TOKEN; |
| + let key = opts.key || process.env.LCHAT_KEY; |
| + const link = opts.link || process.env.LCHAT_LINK; |
| + if (link) ({ host, token, key } = fromLink(link)); |
| + const name = opts.name || process.env.LCHAT_NAME || "bot"; |
| + const type = opts.type || process.env.LCHAT_TYPE || MESSAGE_TYPE_CHAT; |
| + |
| + if (!host || !token || !key) { |
| + throw new Error("need a --link, or all of --host/--token/--key (or the LCHAT_* env vars)"); |
| + } |
| + |
| + const text = (rest.length ? rest.join(" ") : await readStdin()).trim(); |
| + if (!text) throw new Error("nothing to post (empty text)"); |
| + |
| + // Identical shape and crypto to the browser composer. `type` defaults to |
| + // "chat" so other clients render it normally; override it to emit a different |
| + // payload kind (a client only renders kinds it accepts — see message.js). ts |
| + // is the local clock in ms. |
| + const payload = { type, name, text, ts: Date.now() }; |
| + const cryptoKey = await importKey(key); |
| + const blob = await encrypt(cryptoKey, payload); |
| + |
| + const url = `${host.replace(/\/$/, "")}/api/messages?token=${encodeURIComponent(token)}`; |
| + const res = await fetch(url, { |
| + method: "POST", |
| + headers: { "Content-Type": "application/json" }, |
| + body: JSON.stringify({ blob }), |
| + }); |
| + if (!res.ok) { |
| + const detail = await res.text().catch(() => ""); |
| + throw new Error(`server returned ${res.status}: ${detail.trim() || res.statusText}`); |
| + } |
| + const { id } = await res.json(); |
| + process.stderr.write(`posted message #${id}\n`); |
| +} |
| + |
| +main().catch((e) => { |
| + process.stderr.write(`lchat-post: ${e.message}\n`); |
| + process.exit(1); |
| +}); |