@' + escapeHtml(r.reporter) + ' reported @' + escapeHtml(r.reported) + + '
' + escapeHtml(r.reason) + '
' + escapeHtml(when(r.created_at)) + + (r.resolved ? " · Resolved" : "") + '
' + + (r.resolved ? "" : '') + 'diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml
new file mode 100644
index 0000000..caa1fe3
--- /dev/null
+++ b/.github/workflows/verify.yml
@@ -0,0 +1,19 @@
+name: Verify COMA
+
+on:
+ push:
+ branches: [main, coma-2.0-beta]
+ pull_request:
+
+jobs:
+ smoke:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-python@v5
+ with:
+ python-version: '3.12'
+ - name: Python integration smoke test
+ run: python -m unittest discover -s test -p 'test_*.py' -v
+ - name: Check browser JavaScript syntax
+ run: node --check public/app.js
diff --git a/README.md b/README.md
index d298d21..57e653e 100644
--- a/README.md
+++ b/README.md
@@ -1,132 +1,57 @@
-# COMA - Beta
-
-**A place of your own for your people.**
-
-COMA is a small-group chat app. Create an account, invite someone with a personal link, and keep the conversation going in a calm space. It runs on a Python server and stores data in SQLite.
-
-**Version 0.1 beta.** The core features work, but this is an early release for a small test group.
-
-
-
-*Screenshots show the real app with fictional example accounts and messages. A new COMA installation starts empty.*
-
-## What works
-
-- Accounts: register, sign in, change your display name, and sign out.
-- Personal invitations: single-use links valid for seven days that can be revoked.
-- Private conversations between two people with live updates and automatic reconnection.
-- Saved and read status, search, unread filters, and older message history.
-- Contact blocking and abuse reports, which the administrator can review.
-- A responsive interface for desktop and mobile.
-
-## Screenshots
-
-### Welcome screen
-
-
-
-### Mobile conversation
-
- A place of your own for your people. You have a personal invitation. Messages are stored on the server and are not end-to-end encrypted.
-
-## Run on Windows
-
-If you have the built app, double-click **COMA.exe**. A local build is available at `dist/COMA.exe`. Python is not required on the destination computer. COMA opens your browser and a console window. Keep the console open while using the app; press **Ctrl+C** there to stop it.
-
-The Windows executable was built locally and passed its bundled startup test. The `dist/` directory is excluded from Git. A GitHub download will be available only after the executable is attached to a release.
-
-## Run with Python
-
-Requires **Python 3.12 or newer**. The server uses only the Python standard library.
-
-From the COMA directory:
-
-```sh
-python server.py --open
-```
-
-On macOS or Linux, use `python3` if necessary. You can also open **http://127.0.0.1:4310** yourself. On Windows, double-click `start-coma.cmd`. Press **Ctrl+C** to stop the server.
-
-### Start your first conversation
-
-1. Register the first account. It becomes the administrator.
-2. Choose **Invite someone** and copy the personal invitation link.
-3. Open the link in a different browser or private window to test a second account.
-4. Register that account and begin chatting.
-
-Registration needs a name, username, and a password of at least 12 characters. Email is not required. After the first account, new users need an invitation.
-
-By default, COMA listens only on your computer. For conversations across devices, run one shared server behind HTTPS. A link pointing to `127.0.0.1` works only on the computer that created it.
-
-## Build desktop apps
-
-| Platform | Current status |
-| --- | --- |
-| Windows | EXE built locally and startup test passed. |
-| macOS | APP and DMG build scripts are ready; the Mac build has not yet been run or tested. |
-
-To build on Windows, install PyInstaller 6.22.3 and run `python build.py`. A DMG must be built on a Mac. See [BUILDING.md](BUILDING.md) for detailed instructions and storage locations. The macOS build is not notarized or signed with an Apple Developer certificate.
-
-## Data storage
-
-Accounts, password hashes, messages, sessions, and invitations are stored in `coma.sqlite`.
-
-| How you start COMA | Default data directory |
-| --- | --- |
-| `python server.py` | `data/` next to the source code |
-| Windows desktop app | `%LOCALAPPDATA%\COMA\data` |
-| macOS desktop app | `~/Library/Application Support/COMA/data` |
-
-The desktop app does not automatically import the source version's data. Set `DATA_DIR` to use another location. Stop the server before backing up the complete data directory. `.gitignore` excludes personal databases, `.env` files, and local builds. The EXE does not contain developer accounts or messages.
-
-## Privacy and beta limitations
-
-Passwords are hashed with scrypt and a unique salt. Only conversation participants can access their conversation through the app. Sessions expire after seven days. The server checks the origin and CSRF token on write requests and limits sign-in attempts and message sending.
-
-**Messages are not end-to-end encrypted, and the database is not encrypted on disk.** The server administrator can read stored data. Use HTTPS on a shared server.
-
-This beta does not yet support password recovery, email verification, self-service account deletion or data export, group conversations, calls, file attachments, or push notifications when the app is closed. There is no offline outbox. Drafts and unconfirmed messages exist only in the current browser window and may be lost when it closes or reloads.
-
-## Host a small test group
-
-Use one Python process with a persistent data directory. Create the administrator account yourself before making the server accessible to others. Put a TLS reverse proxy in front of COMA and disable buffering for `/api/events`.
-
-| Variable | Default | Purpose |
-| --- | --- | --- |
-| `HOST` | `127.0.0.1` | Listen address for `server.py`. |
-| `PORT` | `4310` | Server port. |
-| `DATA_DIR` | `data/` beside `server.py` | Persistent SQLite directory. |
-| `PUBLIC_ORIGIN` | Derived from the local Host header | Set to the exact HTTPS origin when hosting. |
-| `COOKIE_SECURE` | Off | Set to `1` for HTTPS. |
-| `MAX_USERS` | `100` | Maximum number of accounts. |
-
-The Python server does not load `.env` automatically; set variables in the shell. The desktop launcher binds to `127.0.0.1`, so use `server.py` for hosting. Live connections and rate limits are process-local. Behind a reverse proxy, visitors share that proxy's IP-based limit.
-
-## Tests
-
-```sh
-python -m unittest discover -s test -p test_python.py -v
-```
-
-The four Python integration tests use temporary databases. They cover registration, invitations, authorization, CSRF, messages, live updates, read status, blocking, reports, pagination, restart persistence, and rate limits. The original Node implementation has a separate test suite: `npm test` with Node 24.
-
-## Project layout
-
-```text
-COMA/
- server.py Python server and API
- desktop.py Desktop launcher
- schema.sql SQLite schema
- public/ Browser interface and icon
- docs/screenshots/ Example screenshots
- test/ Python and legacy Node integration tests
- build.py EXE / APP / DMG builder and self-test
- BUILDING.md Desktop build instructions
- README.md
-```
-
-The original Node server (`server.mjs`) remains available. Python and Node use the same interface, database schema, and password hashes. Run only one server at a time against a database.
-
-## Feedback
-
-Describe what you did, what you expected, and what happened. Mention your operating system and whether you used the EXE or Python version. Do not share passwords, session cookies, invitation links, or personal databases in bug reports.
+# COMA 2.0 beta
+
+**A place of your own for your people.**
+
+COMA is a small, private group chat app for one-to-one conversations. It runs locally with Python 3.12 or newer and stores accounts and messages in SQLite. This beta restores the browser interface and provides a working source download.
+
+## Start
+
+```sh
+python server.py --open
+```
+
+On macOS or Linux, use `python3` if needed. Open http://127.0.0.1:4310 if your browser does not open automatically. Keep the server running while you use COMA. The first registered account becomes the administrator.
+
+To chat with someone else, choose **Invite someone** and privately share the single-use link. It expires after seven days. The other person must reach the same server; a link with `127.0.0.1` only works on your own computer.
+
+## Features
+
+- Accounts with a username, display name, and password of at least 12 characters.
+- Personal invitation links and private conversations between two people.
+- Live messages, older message history, read status, and unread counts.
+- Conversation search, contact blocking, and reports for the administrator.
+- Responsive browser interface for desktop and mobile.
+
+## Desktop builds
+
+On Windows, install PyInstaller 6.22.3 and run `python build.py` to create `dist/COMA.exe`. On a Mac, the same command creates a COMA app and a DMG for that Mac's architecture. Read [BUILDING.md](BUILDING.md) for details. These builds require testing on their destination platforms; the source version is the primary beta distribution.
+
+## Data and privacy
+
+The default database is `data/coma.sqlite` beside the source code. Desktop builds use `%LOCALAPPDATA%\\COMA\\data` on Windows and `~/Library/Application Support/COMA/data` on macOS. Set `DATA_DIR` to choose another directory. Stop the server before backing up the complete data directory. The database and `.env` are excluded from Git.
+
+Passwords use salted scrypt hashes. Sessions expire after seven days. The server limits sign-in and message attempts, and checks origin and CSRF tokens on write requests.
+
+**Messages are not end-to-end encrypted and the database is not encrypted on disk.** The server administrator can read stored data. For access across devices, put a single COMA server behind HTTPS and set `PUBLIC_ORIGIN` to its exact origin and `COOKIE_SECURE=1`. The desktop launcher is for local use.
+
+This beta does not support password recovery, self-service account deletion or export, group conversations, calls, attachments, or push notifications. Drafts in the current browser window may be lost when it closes or reloads.
+
+## Tests
+
+```sh
+python -m unittest discover -s test -p "test_*.py" -v
+```
+
+The smoke test starts an isolated temporary server and checks static assets, registration, invitation-only access, private messaging, and CSRF protection. The legacy Node server is available with Node 24; run `npm run check` to check its and the browser script's syntax. Do not run both servers against the same database at once.
+
+## Project layout
+
+```text
+server.py Python server and API
+server.mjs Legacy Node server
+schema.sql SQLite schema
+public/ Browser interface
+desktop.py Desktop launcher
+build.py Desktop builder and bundled startup check
+test/ Integration smoke test
+```
diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md
index 70f3295..eecbbeb 100644
--- a/RELEASE_NOTES.md
+++ b/RELEASE_NOTES.md
@@ -1,9 +1,9 @@
-# COMA 0.1.0 beta 1
+# COMA 2.0.0 beta 1
-The first public beta of COMA brings personal invitations, two-person conversations, live updates, read status, search, blocking, and abuse reports to a small group chat app.
+This beta makes the source download usable again. The GitHub 1.0 tag contained the Python and Node servers but omitted the `public/` browser assets and tests that those servers expected.
-The interface and documentation are in English. The Windows build includes a standalone EXE. The macOS builds provide Apple Silicon and Intel DMGs when the release workflow completes successfully.
+- Restored the responsive browser interface for accounts, personal invitations, private conversations, live messages, read status, search, blocking, and reports.
+- Added an isolated integration smoke test for assets, registration, invitations, messaging, and CSRF.
+- Aligned the package version and documentation with the beta.
-This is an early test release. Messages are not end-to-end encrypted; the server administrator can read the database. Password recovery, account deletion in the interface, group conversations, file sharing, and push notifications are not available yet.
-
-Read the [README](README.md) for setup, storage locations, screenshots, and current limitations.
+This is a beta for testing. Desktop builds need validation on Windows and macOS before they are distributed. Messages are not end-to-end encrypted. See the [README](README.md) for setup and current limitations.
diff --git a/package.json b/package.json
index 9c7918b..a4d89cd 100644
--- a/package.json
+++ b/package.json
@@ -1,16 +1,15 @@
-{
- "name": "coma",
- "version": "0.1.0-beta.1",
- "private": true,
- "type": "module",
- "description": "COMA — a place of your own for your people.",
- "engines": {
- "node": ">=24.14.0 <25"
- },
- "scripts": {
- "start": "node server.mjs",
- "dev": "node --watch server.mjs",
- "test": "node --test test/*.test.mjs",
- "check": "node --check server.mjs && node --check public/app.js"
- }
-}
+{
+ "name": "coma",
+ "version": "2.0.0-beta.1",
+ "private": true,
+ "type": "module",
+ "description": "COMA — a place of your own for your people.",
+ "engines": {
+ "node": ">=24.14.0 <25"
+ },
+ "scripts": {
+ "start": "node server.mjs",
+ "dev": "node --watch server.mjs",
+ "check": "node --check server.mjs && node --check public/app.js"
+ }
+}
diff --git a/public/app.js b/public/app.js
new file mode 100644
index 0000000..0167997
--- /dev/null
+++ b/public/app.js
@@ -0,0 +1,278 @@
+"use strict";
+const root = document.getElementById("app");
+const state = { user: null, csrf: null, setup: false, conversations: [], active: null, messages: [], hasMore: false, peerRead: 0, filter: "all", query: "", mode: "register", stream: null, notice: "", readMarks: {}, drafts: {}, invite: new URLSearchParams(location.search).get("invite") || "" };
+const escapeHtml = value => String(value ?? "").replace(/[&<>"']/g, c => ({"&":"&","<":"<",">":">","\"":""","'":"'"}[c]));
+const when = value => value ? new Date(value).toLocaleString() : "";
+function notify(message) {
+ state.notice = message;
+ const el = document.getElementById("notice");
+ if (el) el.textContent = message;
+ else if (message) alert(message);
+}
+async function api(path, method = "GET", data) {
+ const options = { method, credentials: "same-origin", headers: {} };
+ if (method !== "GET") {
+ options.headers["Content-Type"] = "application/json";
+ if (state.csrf) options.headers["X-CSRF-Token"] = state.csrf;
+ options.body = JSON.stringify(data || {});
+ }
+ let response;
+ try { response = await fetch(path, options); }
+ catch { throw new Error("Could not connect to COMA. Check your connection."); }
+ const result = await response.json();
+ if (!response.ok) {
+ if (response.status === 401 && state.user) {
+ state.user = null;
+ state.csrf = null;
+ state.stream?.close();
+ render();
+ }
+ throw new Error(result.error || "Something went wrong.");
+ }
+ return result;
+}
+async function loadSession() {
+ const result = await api("/api/session");
+ state.user = result.user;
+ state.csrf = result.csrf;
+ state.setup = result.setup;
+ state.mode = state.setup || state.invite ? "register" : "login";
+ render();
+ if (state.user) { await sync(); connect(); }
+}
+function authView() {
+ const register = state.mode === "register";
+ root.innerHTML = '' +
+ (register ? "Create an account" : "Sign in") + '
' +
+ (state.invite ? '
Invite someone to start a private chat.
Copy this link and share it privately. It works once and expires in seven days.
' + + '' + + ''; + document.getElementById("copy-link").onclick = async () => { + try { await navigator.clipboard.writeText(url.href); notify("Invitation link copied."); } + catch { document.getElementById("invite-link").select(); } + }; + document.getElementById("close-link").onclick = () => el.close(); + }); + document.getElementById("profile").onclick = () => dialog("Your profile", + '', + async (form, el) => { const result = await api("/api/profile", "PATCH", {name: form.get("name")}); + state.user = result.user; el.close(); render(); }); + document.getElementById("logout").onclick = async () => { + try { await api("/api/logout", "POST"); } catch (error) { notify(error.message); return; } + state.stream?.close(); state.stream = null; state.user = null; state.csrf = null; + state.active = null; state.conversations = []; state.messages = []; render(); + }; + document.getElementById("accept-invite")?.addEventListener("click", async () => { + try { + const result = await api("/api/invites/accept", "POST", {token: state.invite}); + clearInvite(); await sync(); state.active = result.conversationId; await loadMessages(); + } catch (error) { notify(error.message); } + }); + document.getElementById("composer")?.addEventListener("submit", async event => { + event.preventDefault(); + const form = event.currentTarget; + const body = form.elements.body.value.trim(); + if (!body || !state.active) return; + const button = form.querySelector("button"); + button.disabled = true; + try { + await api("/api/conversations/" + state.active + "/messages", "POST", {body, clientId: crypto.randomUUID()}); + form.reset(); state.drafts[state.active] = ""; await sync(); await loadMessages(); + } catch (error) { notify(error.message); } + finally { button.disabled = false; } + }); + document.getElementById("older")?.addEventListener("click", async () => { + const before = state.messages[0]?.id; + if (!before) return; + try { await loadMessages(before); } catch (error) { notify(error.message); } + }); + document.getElementById("block")?.addEventListener("click", async () => { + const c = activeConversation(); + if (!c) return; + if (!confirm(c.blocked_by_me ? "Unblock this contact?" : "Block this contact?")) return; + try { await api("/api/conversations/" + c.id + "/block", "POST", {blocked: !c.blocked_by_me}); await sync(); } + catch (error) { notify(error.message); } + }); + document.getElementById("report")?.addEventListener("click", () => { + const c = activeConversation(); + if (!c) return; + dialog("Report " + c.name, '', + async (form, el) => { await api("/api/conversations/" + c.id + "/report", "POST", {reason: form.get("reason")}); + el.close(); notify("Report sent to the administrator."); }); + }); + document.getElementById("reports")?.addEventListener("click", async () => { + try { + const result = await api("/api/reports"); + const el = document.getElementById("dialog"); + el.innerHTML = '
@' + escapeHtml(r.reporter) + ' reported @' + escapeHtml(r.reported) + + '
' + escapeHtml(r.reason) + '
' + escapeHtml(when(r.created_at)) + + (r.resolved ? " · Resolved" : "") + '
' + + (r.resolved ? "" : '') + 'No reports.
') + ''; + el.showModal(); + document.getElementById("close-reports").onclick = () => el.close(); + el.querySelectorAll(".resolve").forEach(button => button.onclick = async () => { + try { await api("/api/reports/" + button.dataset.id, "PATCH"); button.replaceWith(document.createTextNode("Resolved")); } + catch (error) { notify(error.message); } + }); + } catch (error) { notify(error.message); } + }); +} +async function sync() { + if (!state.user) return; + const result = await api("/api/conversations"); + state.conversations = result.conversations; + if (state.active && !activeConversation()) { state.active = null; state.messages = []; } + render(); + if (state.active) await loadMessages(); +} +async function loadMessages(before) { + const id = state.active; + if (!id) return; + const path = "/api/conversations/" + id + "/messages" + (before ? "?before=" + before : ""); + const result = await api(path); + if (id !== state.active) return; + state.messages = before ? result.messages.concat(state.messages) : result.messages; + state.hasMore = result.hasMore; + state.peerRead = result.peerRead; + render(); + const list = document.getElementById("messages"); + if (list && !before) list.scrollTop = list.scrollHeight; + const lastIncoming = [...state.messages].reverse().find(message => message.sender_id !== state.user.id); + if (lastIncoming && lastIncoming.id > (state.readMarks[id] || 0)) { + state.readMarks[id] = lastIncoming.id; + api("/api/conversations/" + id + "/read", "POST", {lastId: lastIncoming.id}) + .catch(() => { delete state.readMarks[id]; }); + } +} +function connect() { + state.stream?.close(); + state.stream = new EventSource("/api/events"); + state.stream.addEventListener("sync", () => sync().catch(error => notify(error.message))); + state.stream.onerror = () => { if (state.user) notify("Reconnecting to live updates…"); }; +} +loadSession().catch(error => { root.textContent = error.message; }); diff --git a/public/favicon.svg b/public/favicon.svg new file mode 100644 index 0000000..13ade4f --- /dev/null +++ b/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..0d6fc7b --- /dev/null +++ b/public/index.html @@ -0,0 +1,16 @@ + + + + + + + + + +Opening COMA…