diff --git a/CHANGELOG.md b/CHANGELOG.md index aee3240..3835328 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Added + +- Admins can clear the whole chat history (*Admin → Chat*, or `DELETE /rigshare/api/chat`). Everyone's chat empties at once, followed by a notice saying who cleared it + ### Fixed - Entering the App view closed an open RigShare or Files panel (going back to the graph kept it open). It now stays open both ways diff --git a/docs/api.md b/docs/api.md index 5f6b9d9..0f4e368 100644 --- a/docs/api.md +++ b/docs/api.md @@ -47,6 +47,7 @@ The panel uses these; they take the same session cookie or API key. | `GET /rigshare/api/me` | The current account | | `GET/POST /rigshare/api/me/keys`, `DELETE /rigshare/api/me/keys/{id}` | Your API keys | | `GET /rigshare/api/chat?before=&limit=50` | Older chat messages, oldest first, and whether more exist | +| `DELETE /rigshare/api/chat` | Clear the whole chat history (admin) | | `GET /rigshare/api/rooms` | Live workflows | | `GET /rigshare/api/room?key=…` | One room, with its workflow JSON | | `GET /rigshare/api/snapshots?room=…` | History of a room | diff --git a/docs/settings.md b/docs/settings.md index 43c3c44..8ea42b3 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -34,7 +34,7 @@ Admins change these under **Admin → Server settings**. They are stored in `rig | `room_expiry_days` | `14` | Forget the live state of workflows nobody opened for this long | | `snapshot_interval_sec` | `300` | Automatic snapshot interval while editing; `0` turns it off | | `snapshot_keep` | `30` | Automatic snapshots kept per workflow, and as many on-save ones; named ones are always kept | -| `chat_keep` | `0` | Chat messages kept: `0` keeps the whole history, a number trims it to the latest ones at startup | +| `chat_keep` | `0` | Chat messages kept: `0` keeps the whole history, a number trims it to the latest ones at startup. Admins can also clear it under *Admin → Chat* | ## Environment variables diff --git a/rigshare_core/chatlog.py b/rigshare_core/chatlog.py index 2bd111f..8a6a709 100644 --- a/rigshare_core/chatlog.py +++ b/rigshare_core/chatlog.py @@ -57,6 +57,13 @@ class ChatLog: os.replace(tmp, self.path) self._index() + def clear(self): + """Delete the whole history; numbering starts again at 0.""" + with self._lock: + with open(self.path, "w", encoding="utf-8"): + pass + self.offsets = [] + def __len__(self): return len(self.offsets) diff --git a/rigshare_core/hub.py b/rigshare_core/hub.py index 0059775..6470e96 100644 --- a/rigshare_core/hub.py +++ b/rigshare_core/hub.py @@ -381,6 +381,12 @@ class Hub: async def broadcast_server(self): await self.broadcast({"type": "server", "server": self.server_info()}) + async def clear_chat(self, by): + """An admin wiped the chat: everyone's history empties, then a notice says who.""" + self.chat.clear() + await self.broadcast({"type": "chat_cleared"}) + await self.system_chat(f"{by} cleared the chat history") + async def system_chat(self, text): msg = {"id": secrets.token_hex(6), "system": True, "text": text, "ts": int(time.time() * 1000)} msg = self.chat.append(msg) diff --git a/rigshare_core/routes.py b/rigshare_core/routes.py index 23eb79d..f1cc921 100644 --- a/rigshare_core/routes.py +++ b/rigshare_core/routes.py @@ -356,6 +356,13 @@ def setup(server, store, hub): messages, more = hub.chat.page(before, limit) return web.json_response({"messages": messages, "more": more}) + @routes.delete("/rigshare/api/chat") + async def clear_chat(request): + admin = require_admin(request) + await hub.clear_chat(admin.get("display_name") or admin["username"]) + log.info(f"[RigShare] {admin['username']} cleared the chat history") + return web.json_response({"ok": True}) + @routes.get("/rigshare/api/rooms") async def list_rooms(request): return web.json_response([r for r in hub.room_list() if room_access(request, hub.rooms[r["key"]])]) diff --git a/tests/python/test_server.py b/tests/python/test_server.py index 5f4c2ac..0259a52 100644 --- a/tests/python/test_server.py +++ b/tests/python/test_server.py @@ -440,3 +440,26 @@ def test_every_save_keeps_a_snapshot(run): await h.post(rig.url(f"/api/userdata/{enc(path)}"), data=b'{"nodes": []}', headers=viewer) assert len(rig.store.list_snapshots("file:" + path)) == 2 run(scenario()) + + +def test_admin_clears_chat_history(run): + async def scenario(): + async with rig_server() as rig: + admin = await rig.setup_admin() + alice = await rig.add_user("alice", edit=True) + sock = await rig.ws("alice") + for i in range(5): + await sock.send({"type": "chat", "text": f"hi {i}"}) + await asyncio.sleep(0.2) + await sock.drain() + assert (await rig.http.delete(rig.url("/rigshare/api/chat"), headers=alice)).status == 403, "admins only" + assert (await rig.http.delete(rig.url("/rigshare/api/chat"), headers=admin)).status == 200 + msgs = await sock.drain() + kinds = [m["type"] for m in msgs] + assert "chat_cleared" in kinds and kinds.index("chat_cleared") < kinds.index("chat") + notice = next(m for m in msgs if m["type"] == "chat")["message"] + assert notice["system"] and "cleared the chat history" in notice["text"] and notice["seq"] == 0 + history = await (await rig.http.get(rig.url("/rigshare/api/chat"), headers=alice)).json() + assert [m["text"] for m in history["messages"]] == [notice["text"]] and not history["more"] + await sock.close() + run(scenario()) diff --git a/web/rigshare/client.js b/web/rigshare/client.js index f5ddb8a..6f1b753 100644 --- a/web/rigshare/client.js +++ b/web/rigshare/client.js @@ -146,6 +146,11 @@ export class RigClient extends EventTarget { this.stats = msg.stats; this.emit("stats", msg.stats); break; + case "chat_cleared": + this.chat = []; + this.chatMore = false; + this.emit("chat-history", this.chat); + break; case "chat": this.chat.push(msg.message); this.emit("chat", msg.message); diff --git a/web/rigshare/panel.js b/web/rigshare/panel.js index 70944bc..fc456e3 100644 --- a/web/rigshare/panel.js +++ b/web/rigshare/panel.js @@ -687,6 +687,14 @@ export class RigSharePanel { return [ this.section("pi-users", "Accounts", this.adminAccounts, this.createAccountForm()), this.section("pi-cog", "Server settings", this.adminSettings), + this.section("pi-comments", "Chat", + h("p", { class: "rs-muted rs-small" }, "The whole chat history is kept. Clearing it deletes every message for everyone; it cannot be undone."), + h("div", { class: "rs-actions" }, h("button", { + class: "rs-btn rs-danger", onclick: async () => { + if (!(await this.confirm("Clear chat history", "Delete every chat message for everyone? This cannot be undone."))) return; + await this.run(() => this.client.request("DELETE", "/rigshare/api/chat"), "Chat history cleared"); + }, + }, h("i", { class: "pi pi-trash" }), "Clear chat history"))), ]; }