Add contacts and per-call Jitsi servers

Everyone signs in at the one central Quine identity server and is called
by username. The caller picks which Jitsi Meet server each call happens
on (meet.jit.si by default, or any other deployment), and the invite
sends the callee to the same one. Jitsi servers need no credentials from
Quple.

- server: POST /call takes an optional jitsiUrl (http(s) only, no
  credentials/query/fragment; default DEFAULT_JITSI) and returns it with
  the meeting ID; invites carry it. Adds GET /me and per-person contacts
  with presence (persisted in DATA_DIR), replacing GET /users.
- desktop: "On server" field for calls, remembered as the last-used
  server; incoming calls show which server they're on; open_meeting
  refuses non-http(s) URLs. meet.jit.si sign-in is now an inline prompt
  when calling there rather than a gate in front of the whole app.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Philip Peterson 2026-10-09 05:37:31 -08:00
parent 6e75b01735
commit 96f7134c51
13 changed files with 824 additions and 300 deletions

1
.gitignore vendored
View file

@ -16,3 +16,4 @@
# Identity server data: signing key, accounts, refresh tokens
/identity/data/
/identity/quine-identity
/server/data/

View file

@ -9,41 +9,48 @@ other and agree on a meeting room.
## How it works
1. **Sign in.** Everything is behind a Quine account
(`identity.quineglobal.com`, an OpenID Connect provider in `identity/`).
The app opens the sign-in page in the system browser; your Quine username
is the address people call you at. Then, when calls run on the public
`meet.jit.si`, the app asks you to sign in there once too (meet.jit.si only
starts a meeting when a signed-in user joins), and every meeting reuses
that session.
1. **Sign in.** Everything is behind a Quine account at the one central
identity server, `identity.quineglobal.com` (an OpenID Connect provider in
`identity/`). The app opens the sign-in page in the system browser. Your
Quine username is what people call you by. **Contacts** are kept per
person on the signaling server.
2. **Presence.** While the app is open, it polls the signaling server every
few seconds, which doubles as "I'm online" and "did anyone call me?".
3. **Calling.** You call someone by username. The app asks the signaling
server for a meeting ID, then opens a window pointed at
`<jitsi-server>/<meeting-id>`.
3. **Calling.** You call someone by username, on whichever Jitsi Meet
server you like: meet.jit.si by default, or any other deployment (the
app remembers the last one you used). Jitsi servers need no credentials
from Quple, so nothing is set up on them. The signaling server mints a
meeting ID and your app opens a window at `<jitsi>/<meeting-id>`.
4. **Receiving.** The callee's app sees the pending invite on its next poll,
shows an "Incoming call from X" prompt, and — if accepted — opens the same
meeting URL. Jitsi Meet's own WebRTC signaling (XMPP + the videobridge)
takes it from there; Quple's server never touches media or call content,
it only introduces two clients to the same room name.
shows an "Incoming call from X on <server>" prompt, and — if accepted —
opens the same meeting URL on the caller's chosen server. Jitsi Meet's
own WebRTC signaling (XMPP + the videobridge) takes it from there;
Quple's server never touches media or call content, it only introduces
two clients to the same room name.
Every request to the signaling server carries a Quine access token; the
server takes the caller's username from it, never from the request.
meet.jit.si only starts a meeting once a signed-in user joins, so when the
chosen server is meet.jit.si the app offers to sign you in to it (once; it's
remembered).
```
Caller's Quple Signaling server Callee's Quple
-------------- ---------------- --------------
POST /call {to} -----> mint meetingId,
POST /call -----> mint meetingId,
{to, jitsiUrl}
queue invite for "to"
<----- {meetingId}
<----- {meetingId, jitsiUrl}
open window:
jitsi.example/<id>
<jitsiUrl>/<id>
GET /poll
<----- (polling every ~3s)
return queued invite ----->
show "Incoming call"
show "Incoming call
from X on <server>"
accept -> open window:
jitsi.example/<id>
<jitsiUrl>/<id>
```
Both windows are now just two independent visitors to the same Jitsi Meet
@ -59,8 +66,9 @@ quple/
├── server/ # Go: the minimal central signaling service
├── desktop/ # Tauri desktop app (the thing each user runs); the UI is
│ # Preact + htm, vendored, so there's no frontend build
└── jitsi-meet/ # Vendored upstream jitsi-meet source (reference / future
# self-hosting; not built as part of this app — see below)
└── jitsi-meet/ # Upstream jitsi-meet source, as its own git clone (ignored
# here; reference only, not built as part of this app —
# see below)
```
### Why `jitsi-meet/` isn't built
@ -72,9 +80,8 @@ no infrastructure at all. Running your own deployment needs Prosody (XMPP),
Jicofo, and JVB in addition to the web frontend in this repo — that's a
separate, heavier project (see jitsi-meet's own `doc/quick-install.md` and
`docker-compose` setup). The source is vendored here so it's easy to build a
custom/branded frontend and point `jitsiBase` at it later; swap it in via the
app's Settings screen (or `meet.quineglobal.com`'s own default) once you have
one running.
custom/branded frontend later; once one is running, just type its URL as the
server when calling (or make it the signaling server's `DEFAULT_JITSI`).
## Running it
@ -123,10 +130,13 @@ go run . # listens on :8080; set ADDR to change it
`ISSUER` (default `https://identity.quineglobal.com`) is the identity server
whose access tokens it accepts and `CLIENT_ID` (default `quple`) the app they
must be issued to. Every endpoint needs `Authorization: Bearer <access token>`.
`DEFAULT_JITSI` (default `https://meet.jit.si`) hosts calls that don't name a
server, and `DATA_DIR` holds contacts.
Deploy this wherever `meet.quineglobal.com` (or whatever host name you pick)
resolves to — it's stateless-enough to run as a single small process; restart
just clears presence and any in-flight invites.
just clears presence and any in-flight invites (contacts are kept in
`DATA_DIR`).
### Local end-to-end setup
@ -134,6 +144,7 @@ just clears presence and any in-flight invites.
(cd identity && ISSUER=http://127.0.0.1:8081 ADDR=127.0.0.1:8081 go run . &)
(cd server && ISSUER=http://127.0.0.1:8081 ADDR=127.0.0.1:8090 go run . &)
(cd identity && go run . adduser alice)
(cd identity && go run . adduser joe)
```
Then in the app's **Advanced settings** set the identity server to
@ -159,9 +170,9 @@ cargo run
On first launch you'll land on **Sign in with Quine**, which finishes in your
browser and comes back to the app (RFC 8252: a one-shot listener on
`127.0.0.1`). The refresh token is kept in the app's data dir, readable only
by you; the webview only ever sees short-lived access tokens. Server URLs
(identity, signaling, Jitsi) are under **Advanced settings**, and later the
gear icon.
by you; the webview only ever sees short-lived access tokens. The identity
and signaling server URLs are under **Advanced settings**, and later the gear
icon. The Jitsi server for a call is picked on the main screen.
To build a standalone app bundle:
@ -179,27 +190,29 @@ cargo tauri build
to the provider's user ID, and tokens verified with `coreos/go-oidc` the
way the signaling server does it. The GitHub/Google round trip itself
(goth) has **not** been exercised: it needs real OAuth app credentials.
- **`server/`**: `go vet`-clean with unit tests for call/drain/presence, and
exercised end-to-end against a local identity server: no token, garbage
tokens and ID tokens are refused; a `from` in the body is ignored in favour
of the token's user; refreshed access tokens work, and refresh tokens
survive an identity-server restart.
- **`desktop/`**: builds with `cargo build` on macOS and runs. The Preact UI's
gating (Quine sign-in, then meet.jit.si, then dashboard; sign-out; lapsed
sessions) is checked headlessly against a mocked backend. The browser
sign-in round trip in the real app hasn't been driven automatically.
- **`server/`**: `go vet`-clean, with HTTP-level tests for auth, calls on
the default and a caller-chosen Jitsi server (and refusal of non-http(s)
or otherwise odd server URLs), and contacts.
- **`desktop/`**: builds with `cargo build` on macOS and runs. The Preact UI
(Quine sign-in, contacts, picking the server for a call, the meet.jit.si
sign-in prompt, incoming calls showing their server) is checked headlessly
against a mocked backend. The browser sign-in round trip in the real app
hasn't been driven automatically.
## Server API
All endpoints require `Authorization: Bearer <Quine access token>`; the
caller's username is the token's subject.
| Method & path | Body | Returns |
|--------------------|----------------|--------------------------------------------|
| `POST /register` | — | `{"ok":true}` — marks you online |
| `GET /users` | — | `{"users":["alice","bob"]}` — online now |
| `POST /call` | `{"to":"bob"}` | `{"meetingId":"quple-ab12cd34"}` |
| `GET /poll` | — | `{"invites":[{"from":"alice","meetingId":"quple-ab12cd34","createdAt":"..."}]}` — also marks you online, and clears delivered invites |
| Method & path | Body | Returns |
|-------------------------------|-----------------------------------------|---------|
| `GET /me` | — | `{"username":"alice","defaultJitsiUrl":"https://meet.jit.si"}` |
| `POST /register` | — | `{"ok":true}` — marks you online |
| `POST /call` | `{"to":"bob","jitsiUrl":"https://meet.jit.si"}` | `{"meetingId":…,"jitsiUrl":…}` — `jitsiUrl` is optional (default `DEFAULT_JITSI`) |
| `GET /poll` | — | `{"invites":[{"from":…,"meetingId":…,"jitsiUrl":…,"createdAt":…}]}` — also marks you online, and clears delivered invites |
| `GET /contacts` | — | `{"contacts":[{"username":…,"online":true}]}` |
| `POST /contacts` | `{"username":"bob"}` | the added contact |
| `DELETE /contacts/{username}` | — | `{"ok":true}` |
Presence expires 90s after the last `/register` or `/poll`. Undelivered
invites expire after 2 minutes.

View file

@ -9,7 +9,8 @@ use tauri::{Emitter, Manager, Url, WebviewUrl, WebviewWindowBuilder};
/// Everything the app needs to remember between launches: which servers to
/// talk to. Saved as plain JSON in the app's data dir - there's nothing
/// sensitive in it (the Quine sign-in is kept separately, see quine.rs).
/// Who you are comes from your Quine account.
/// Who you are comes from your Quine account. `jitsi_base` is the Jitsi
/// Meet server you last called someone on, which the next call starts from.
#[derive(Serialize, Deserialize, Clone)]
struct AppConfig {
#[serde(default = "default_server_url")]
@ -251,6 +252,12 @@ fn open_meeting(
if room.is_empty() {
return Err("room is required".into());
}
// Whoever calls picks the server, so the callee's window must only ever
// go to a web page.
match Url::parse(jitsi_base.trim()) {
Ok(u) if matches!(u.scheme(), "https" | "http") && u.host_str().is_some() => {}
_ => return Err(format!("{jitsi_base} isn't a Jitsi Meet server URL")),
}
let label = format!("meeting-{room}");
if let Some(existing) = app.get_webview_window(&label) {
@ -278,7 +285,7 @@ fn open_meeting(
&config.prejoinConfig.enabled=false&config.prejoinPageEnabled=false",
percent_encode(&name_json)
);
Url::parse(&url).map_err(|e| format!("invalid meeting URL {url}: {e}"))?
Url::parse(&url).map_err(|_| format!("invalid meeting URL for {base}/{room}"))?
};
let app_ = app.clone();

View file

@ -8,12 +8,12 @@ const { listen } = window.__TAURI__.event;
const DEFAULTS = {
serverUrl: "https://meet.quineglobal.com",
jitsiBase: "https://meet.jit.si",
identityUrl: "https://identity.quineglobal.com",
jitsiBase: "https://meet.jit.si",
};
const POLL_INTERVAL_MS = 3000;
const USERS_REFRESH_MS = 4000;
const CONTACTS_REFRESH_MS = 4000;
// ---- Backend helpers ---------------------------------------------------------
@ -21,38 +21,73 @@ async function loadConfig() {
const saved = await invoke("load_config");
return {
serverUrl: saved.server_url || DEFAULTS.serverUrl,
jitsiBase: saved.jitsi_base || DEFAULTS.jitsiBase,
identityUrl: saved.identity_url || DEFAULTS.identityUrl,
jitsiBase: saved.jitsi_base || DEFAULTS.jitsiBase,
};
}
async function saveConfig(config) {
await invoke("save_config", {
config: {
server_url: config.serverUrl,
jitsi_base: config.jitsiBase,
identity_url: config.identityUrl,
},
config: { server_url: config.serverUrl, identity_url: config.identityUrl, jitsi_base: config.jitsiBase },
});
}
/** meet.jit.si won't start a meeting until a signed-in user joins it. */
function usesPublicJitsi(config) {
try {
return new URL(config.jitsiBase).host === "meet.jit.si";
} catch (_) {
return false;
}
}
const sameUrl = (a, b) => a.replace(/\/+$/, "") === b.replace(/\/+$/, "");
// ---- App: gates everything behind Quine, then meet.jit.si --------------------
/**
* A fetch wrapper for the signaling server that sends the Quine access token,
* retrying once with a fresh token if it's rejected.
*/
function makeApi(serverUrl, accessToken) {
return async (path, options = {}) => {
const send = async (token) =>
fetch(serverUrl.replace(/\/+$/, "") + path, {
...options,
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
});
let res = await send(await accessToken());
if (res.status === 401) {
// The token may have been revoked or the key rotated; retry once fresh.
res = await send(await accessToken(true));
}
const body = await res.json().catch(() => ({}));
if (!res.ok) {
const err = new Error(body.error || res.statusText || `request failed (${res.status})`);
err.status = res.status;
throw err;
}
return body;
};
}
/** The host of a Jitsi server URL, for display; "" if it isn't one. */
function jitsiHost(url) {
try {
return new URL(url.includes("://") ? url : `https://${url}`).host;
} catch (_) {
return "";
}
}
/**
* Meetings on the public meet.jit.si only start once a signed-in user joins,
* so it's worth signing in to it once before calling there.
*/
const isPublicJitsi = (url) => jitsiHost(url) === "meet.jit.si";
function openMeeting(me, jitsiUrl, meetingId) {
return invoke("open_meeting", { jitsiBase: jitsiUrl, room: meetingId, displayName: me.username });
}
// ---- App: gates everything behind Quine ------------------------------------
function App() {
const [config, setConfig] = useState(null);
/** @type {[{username: string, accessToken: string, expiresAt: number} | null, Function]} */
const [session, setSession] = useState(null);
/** Who the signaling server says we are: {username, defaultJitsiUrl}. */
const [me, setMe] = useState(null);
const [meError, setMeError] = useState("");
const [jitsiSignedIn, setJitsiSignedIn] = useState(false);
const [booting, setBooting] = useState(true);
const [bootError, setBootError] = useState("");
@ -90,9 +125,27 @@ function App() {
}
}, []);
const signedIn = session !== null;
const api = config ? makeApi(config.serverUrl, accessToken) : null;
// Once signed in, check the signaling server accepts us.
const loadMe = useCallback(async () => {
setMeError("");
try {
setMe(await api("/me"));
} catch (err) {
if (signedIn) setMeError(err.message);
}
}, [config, signedIn]);
useEffect(() => {
setMe(null);
if (signedIn && config) loadMe();
}, [signedIn, config && config.serverUrl]);
/** Signs out of everything: Quine, and meet.jit.si with it. */
const signOut = useCallback(async () => {
setSession(null);
setMe(null);
setShowSettings(false);
await invoke("jitsi_sign_out").catch(() => {});
await invoke("quine_sign_out");
@ -110,19 +163,25 @@ function App() {
);
if (booting || !config) {
return html`<section class="screen"><p class="muted center">Signing you in…</p></section>`;
return html`<${Message} text="Signing you in…" />`;
}
if (!session) {
return html`<${SignIn} config=${config} initialError=${bootError} onConfig=${updateConfig} onSignedIn=${setSession} />`;
}
if (usesPublicJitsi(config) && !jitsiSignedIn) {
return html`<${JitsiGate} username=${session.username} onSignOut=${signOut} />`;
if (meError) {
return html`<${Message} text=${`Can't use ${config.serverUrl}: ${meError}`}>
<button type="button" onClick=${loadMe}>Try again</button>
<button type="button" class="secondary" onClick=${signOut}>Sign out</button>
<//>`;
}
if (!me) {
return html`<${Message} text="Connecting…" />`;
}
if (showSettings) {
return html`<${Settings}
config=${config}
username=${session.username}
canSignOutOfJitsi=${usesPublicJitsi(config) && jitsiSignedIn}
me=${me}
canSignOutOfJitsi=${jitsiSignedIn}
onSave=${async (next) => {
await updateConfig(next);
setShowSettings(false);
@ -132,13 +191,24 @@ function App() {
/>`;
}
return html`<${Dashboard}
config=${config}
username=${session.username}
accessToken=${accessToken}
me=${me}
api=${api}
jitsiBase=${config.jitsiBase}
jitsiSignedIn=${jitsiSignedIn}
onJitsiBase=${(jitsiBase) => updateConfig({ ...config, jitsiBase })}
onSettings=${() => setShowSettings(true)}
/>`;
}
function Message({ text, children }) {
return html`
<section class="screen">
<p class="muted center">${text}</p>
${children}
</section>
`;
}
// ---- Step 1: Quine sign-in ---------------------------------------------------
function SignIn({ config, initialError, onConfig, onSignedIn }) {
@ -160,7 +230,9 @@ function SignIn({ config, initialError, onConfig, onSignedIn }) {
<section class="screen">
<div class="card">
<h1>Welcome to Quple</h1>
<p class="muted">Sign in with your Quine account. Your Quine username is what people call you at.</p>
<p class="muted">
Sign in with your Quine account. Your Quine username is what people call you by.
</p>
${error && html`<p class="error">${error}</p>`}
${waiting
? html`
@ -177,7 +249,7 @@ function SignIn({ config, initialError, onConfig, onSignedIn }) {
`;
}
/** The three server URLs, as a form. */
/** The identity and signaling server URLs, as a form. */
function ServerFields({ config, submitLabel, onSubmit, onCancel }) {
const [fields, setFields] = useState(config);
useEffect(() => setFields(config), [config]);
@ -191,7 +263,7 @@ function ServerFields({ config, submitLabel, onSubmit, onCancel }) {
const submit = (e) => {
e.preventDefault();
const clean = (key) => fields[key].trim() || DEFAULTS[key];
onSubmit({ identityUrl: clean("identityUrl"), serverUrl: clean("serverUrl"), jitsiBase: clean("jitsiBase") });
onSubmit({ ...config, identityUrl: clean("identityUrl"), serverUrl: clean("serverUrl") });
};
return html`
@ -200,8 +272,6 @@ function ServerFields({ config, submitLabel, onSubmit, onCancel }) {
<input id="identity-url" ...${bind("identityUrl")} />
<label for="server-url">Signaling server URL</label>
<input id="server-url" ...${bind("serverUrl")} />
<label for="jitsi-url">Jitsi Meet server URL</label>
<input id="jitsi-url" ...${bind("jitsiBase")} />
<div class="button-row">
${onCancel && html`<button type="button" class="secondary" onClick=${onCancel}>Cancel</button>`}
<button type="submit" class=${onCancel ? "" : "secondary"}>${submitLabel}</button>
@ -210,31 +280,6 @@ function ServerFields({ config, submitLabel, onSubmit, onCancel }) {
`;
}
// ---- Step 2: meet.jit.si sign-in ---------------------------------------------
function JitsiGate({ username, onSignOut }) {
const [error, setError] = useState("");
const signIn = () => invoke("jitsi_sign_in").catch((err) => setError(String(err)));
return html`
<section class="screen">
<div class="card">
<h1>One more step</h1>
<p class="muted">
Calls run on meet.jit.si, which only starts a meeting once someone signed in joins it. Sign in once here
and your calls will start straight away.
</p>
${error && html`<p class="error">${error}</p>`}
<button type="button" onClick=${signIn}>Sign in to meet.jit.si</button>
</div>
<p class="muted center">
Signed in to Quine as <strong>${username}</strong> ·${" "}
<button type="button" class="link-btn" onClick=${onSignOut}>Sign out</button>
</p>
</section>
`;
}
// ---- Dashboard ---------------------------------------------------------------
/** Like setInterval, but runs immediately and always calls the latest fn. */
@ -248,40 +293,24 @@ function useInterval(fn, ms) {
}, [ms]);
}
function Dashboard({ config, username, accessToken, onSettings }) {
const [users, setUsers] = useState([]);
function Dashboard({ me, api, jitsiBase, jitsiSignedIn, onJitsiBase, onSettings }) {
const [contacts, setContacts] = useState(null);
const [status, setStatus] = useState({ text: "", state: "" });
const [invites, setInvites] = useState([]);
const [target, setTarget] = useState("");
// The Jitsi server the next call happens on; whoever answers joins it there.
const [server, setServer] = useState(jitsiBase);
const api = useCallback(
async (path, options = {}) => {
const send = async (token) =>
fetch(config.serverUrl.replace(/\/+$/, "") + path, {
...options,
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
});
let res = await send(await accessToken());
if (res.status === 401) {
// The token may have been revoked or the key rotated; retry once fresh.
res = await send(await accessToken(true));
}
const body = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(body.error || res.statusText || `request failed (${res.status})`);
return body;
},
[config.serverUrl, accessToken],
);
useInterval(async () => {
const refreshContacts = async () => {
try {
const { users } = await api("/users");
setUsers((users || []).filter((u) => u !== username));
setStatus({ text: `Connected to ${config.serverUrl}`, state: "online" });
const { contacts } = await api("/contacts");
setContacts(contacts || []);
setStatus({ text: `Connected as ${me.username}`, state: "online" });
} catch (err) {
setStatus({ text: `Can't reach signaling server: ${err.message}`, state: "offline" });
}
}, USERS_REFRESH_MS);
};
useInterval(refreshContacts, CONTACTS_REFRESH_MS);
// Polling also tells the server we're online.
useInterval(async () => {
@ -289,38 +318,60 @@ function Dashboard({ config, username, accessToken, onSettings }) {
const { invites } = await api("/poll");
if (invites && invites.length > 0) setInvites((queue) => [...queue, ...invites]);
} catch (_) {
// Failures are already surfaced by the users refresh above.
// Failures are already surfaced by the contacts refresh above.
}
}, POLL_INTERVAL_MS);
const openMeeting = (meetingId) =>
invoke("open_meeting", { jitsiBase: config.jitsiBase, room: meetingId, displayName: username });
const call = async (who) => {
who = who.trim().toLowerCase();
if (!who || who === username) return;
who = who.trim();
if (!who) return;
try {
const { meetingId } = await api("/call", { method: "POST", body: JSON.stringify({ to: who }) });
await openMeeting(meetingId);
const { meetingId, jitsiUrl } = await api("/call", {
method: "POST",
body: JSON.stringify({ to: who, jitsiUrl: server.trim() || me.defaultJitsiUrl }),
});
await openMeeting(me, jitsiUrl, meetingId);
setServer(jitsiUrl);
if (jitsiUrl !== jitsiBase) onJitsiBase(jitsiUrl);
} catch (err) {
setStatus({ text: `Couldn't call ${who}: ${err.message}`, state: "offline" });
}
};
const addContact = async (username) => {
try {
await api("/contacts", { method: "POST", body: JSON.stringify({ username: username.trim() }) });
setTarget("");
await refreshContacts();
} catch (err) {
setStatus({ text: `Couldn't add ${username.trim()}: ${err.message}`, state: "offline" });
}
};
const removeContact = async (username) => {
try {
await api(`/contacts/${encodeURIComponent(username)}`, { method: "DELETE" });
await refreshContacts();
} catch (err) {
setStatus({ text: `Couldn't remove ${username}: ${err.message}`, state: "offline" });
}
};
const [incoming, ...rest] = invites;
const isContact = (name) => (contacts || []).some((c) => c.username === name);
return html`
<section class="screen">
<header>
<div>
<div class="me-label">You</div>
<div class="me-username">${username}</div>
<div class="me-username">${me.username}</div>
</div>
<button class="icon-btn" title="Settings" type="button" onClick=${onSettings}>⚙</button>
</header>
<div class="card">
<label for="call-target">Call someone</label>
<label for="call-target">Call or add someone</label>
<form
id="call-form"
onSubmit=${(e) => {
@ -333,31 +384,73 @@ function Dashboard({ config, username, accessToken, onSettings }) {
id="call-target"
autocomplete="off"
spellcheck=${false}
placeholder="their username"
placeholder="Quine username"
required
value=${target}
onInput=${(e) => setTarget(e.currentTarget.value)}
/>
<button type="submit">Call</button>
</form>
<button
type="button"
class="secondary"
disabled=${!target.trim()}
onClick=${() => addContact(target)}
>
Add to contacts
</button>
<label for="call-server">On server</label>
<input
id="call-server"
list="known-servers"
autocomplete="off"
spellcheck=${false}
placeholder=${me.defaultJitsiUrl}
value=${server}
onInput=${(e) => setServer(e.currentTarget.value)}
/>
<datalist id="known-servers">
${[...new Set([me.defaultJitsiUrl, DEFAULTS.jitsiBase, jitsiBase])].map((u) => html`<option value=${u} />`)}
</datalist>
${isPublicJitsi(server || me.defaultJitsiUrl) &&
!jitsiSignedIn &&
html`<p class="muted">
meet.jit.si only starts a meeting once someone signed in joins it.${" "}
<button type="button" class="link-btn" onClick=${() =>
invoke("jitsi_sign_in").catch((err) => setStatus({ text: String(err), state: "offline" }))}
>
Sign in to meet.jit.si
</button>
</p>`}
</div>
<div class="card">
<div class="list-header">
<span>Online now</span>
<span>Contacts</span>
<span class=${`status-dot ${status.state}`} title=${status.text || "Connecting…"}></span>
</div>
<ul class="user-list">
${users.length === 0
? html`<li class="empty">No one else is online right now.</li>`
: users.map(
(u) => html`
<li class="user-row" key=${u}>
<span class="name">${u}</span>
<button type="button" onClick=${() => call(u)}>Call</button>
</li>
`,
)}
${contacts === null
? html`<li class="empty">Loading…</li>`
: contacts.length === 0
? html`<li class="empty">No contacts yet. Add someone by their username above.</li>`
: contacts.map(
(c) => html`
<li class="user-row" key=${c.username}>
<span class=${`presence ${c.online ? "online" : ""}`} title=${c.online ? "Online" : "Offline"}></span>
<span class="name">${c.username}</span>
<button type="button" onClick=${() => call(c.username)}>Call</button>
<button
type="button"
class="icon-btn remove"
title=${`Remove ${c.username}`}
onClick=${() => removeContact(c.username)}
>
×
</button>
</li>
`,
)}
</ul>
</div>
@ -367,25 +460,32 @@ function Dashboard({ config, username, accessToken, onSettings }) {
${incoming &&
html`<${IncomingCall}
from=${incoming.from}
server=${jitsiHost(incoming.jitsiUrl)}
canAdd=${!isContact(incoming.from)}
onAdd=${() => addContact(incoming.from)}
onAccept=${() => {
setInvites(rest);
openMeeting(incoming.meetingId);
openMeeting(me, incoming.jitsiUrl, incoming.meetingId).catch((err) =>
setStatus({ text: `Couldn't join ${incoming.from}: ${err}`, state: "offline" }),
);
}}
onDecline=${() => setInvites(rest)}
/>`}
`;
}
function IncomingCall({ from, onAccept, onDecline }) {
function IncomingCall({ from, server, canAdd, onAdd, onAccept, onDecline }) {
return html`
<div class="overlay">
<div class="card call-card">
<div class="ringing">Incoming call</div>
<div class="incoming-from">${from}</div>
<div class="muted">on ${server}</div>
<div class="button-row">
<button type="button" class="secondary" onClick=${onDecline}>Decline</button>
<button type="button" onClick=${onAccept}>Accept</button>
</div>
${canAdd && html`<button type="button" class="link-btn add-contact" onClick=${onAdd}>Add to contacts</button>`}
</div>
</div>
`;
@ -393,12 +493,14 @@ function IncomingCall({ from, onAccept, onDecline }) {
// ---- Settings ----------------------------------------------------------------
function Settings({ config, username, canSignOutOfJitsi, onSave, onCancel, onSignOut }) {
function Settings({ config, me, canSignOutOfJitsi, onSave, onCancel, onSignOut }) {
return html`
<section class="screen">
<div class="card">
<h1>Settings</h1>
<p class="muted">Signed in to Quine as <strong>${username}</strong>.</p>
<p class="muted">
Signed in as <strong>${me.username}</strong>.
</p>
<${ServerFields} config=${config} submitLabel="Save" onSubmit=${onSave} onCancel=${onCancel} />
</div>
<div class="card">

View file

@ -278,6 +278,47 @@ header {
text-align: center;
}
.user-row {
gap: 10px;
}
.user-row .name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.presence {
flex: none;
width: 8px;
height: 8px;
border-radius: 999px;
background: var(--border);
}
.presence.online {
background: var(--success);
}
.user-row button.remove {
width: 28px;
height: 28px;
padding: 0;
color: var(--muted);
}
button:disabled {
opacity: 0.5;
cursor: default;
}
button.add-contact {
display: block;
margin: 12px auto 0;
}
.error {
color: var(--danger);
font-size: 13px;

151
server/app_test.go Normal file
View file

@ -0,0 +1,151 @@
package main
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
)
// fakeVerifier treats the bearer token as the caller's subject.
type fakeVerifier struct{}
func (fakeVerifier) Verify(_ context.Context, token string) (string, error) {
if token == "" || token == "bad" {
return "", errors.New("invalid")
}
return token, nil
}
func newTestApp(t *testing.T) *httptest.Server {
t.Helper()
contacts, err := LoadContacts(filepath.Join(t.TempDir(), "contacts.json"))
if err != nil {
t.Fatal(err)
}
app := &App{
verifier: fakeVerifier{},
store: NewStore(),
contacts: contacts,
defaultJitsiURL: "https://meet.jit.si",
}
mux := http.NewServeMux()
app.Routes(mux)
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
return srv
}
// do sends a request as `as` (the fake token) and decodes the JSON reply.
func do(t *testing.T, srv *httptest.Server, as, method, path, body string) (int, map[string]any) {
t.Helper()
var r io.Reader
if body != "" {
r = strings.NewReader(body)
}
req, _ := http.NewRequest(method, srv.URL+path, r)
if as != "" {
req.Header.Set("Authorization", "Bearer "+as)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
var out map[string]any
_ = json.NewDecoder(resp.Body).Decode(&out)
return resp.StatusCode, out
}
func TestAuth(t *testing.T) {
srv := newTestApp(t)
if status, _ := do(t, srv, "", "GET", "/me", ""); status != 401 {
t.Errorf("no token: %d", status)
}
if status, _ := do(t, srv, "bad", "GET", "/me", ""); status != 401 {
t.Errorf("bad token: %d", status)
}
status, me := do(t, srv, "alice", "GET", "/me", "")
if status != 200 || me["username"] != "alice" || me["defaultJitsiUrl"] != "https://meet.jit.si" {
t.Errorf("/me: %d %v", status, me)
}
}
func TestCallOnDefaultServer(t *testing.T) {
srv := newTestApp(t)
status, call := do(t, srv, "alice", "POST", "/call", `{"to":"Bob"}`)
if status != 200 || call["jitsiUrl"] != "https://meet.jit.si" || call["meetingId"] == "" {
t.Fatalf("call: %d %v", status, call)
}
_, poll := do(t, srv, "bob", "GET", "/poll", "")
inv := poll["invites"].([]any)[0].(map[string]any)
if inv["from"] != "alice" || inv["jitsiUrl"] != "https://meet.jit.si" || inv["meetingId"] != call["meetingId"] {
t.Fatalf("invite: %v", inv)
}
}
func TestCallOnAnotherServer(t *testing.T) {
srv := newTestApp(t)
// The caller picks any Jitsi; the callee is sent to the same one.
status, call := do(t, srv, "alice", "POST", "/call", `{"to":"bob","jitsiUrl":"Jitsi.Example.org/"}`)
if status != 200 || call["jitsiUrl"] != "https://jitsi.example.org" {
t.Fatalf("call: %d %v", status, call)
}
_, poll := do(t, srv, "bob", "GET", "/poll", "")
inv := poll["invites"].([]any)[0].(map[string]any)
if inv["jitsiUrl"] != "https://jitsi.example.org" || inv["meetingId"] != call["meetingId"] {
t.Fatalf("invite: %v", inv)
}
}
func TestCallValidation(t *testing.T) {
srv := newTestApp(t)
for _, tc := range []struct{ body, why string }{
{`{"to":"alice"}`, "self"},
{`{"to":"not a name"}`, "malformed name"},
{`{"to":"bob@example.com"}`, "not a username"},
{`{"to":"bob","jitsiUrl":"javascript:alert(1)"}`, "non-http server"},
{`{"to":"bob","jitsiUrl":"file:///etc/passwd"}`, "file server"},
{`{"to":"bob","jitsiUrl":"https://x.example/?a=b"}`, "query in server"},
{`{"to":"bob","jitsiUrl":"https://user@x.example"}`, "credentials in server"},
} {
if status, _ := do(t, srv, "alice", "POST", "/call", tc.body); status != 400 {
t.Errorf("%s: status %d", tc.why, status)
}
}
}
func TestContacts(t *testing.T) {
srv := newTestApp(t)
if status, _ := do(t, srv, "alice", "POST", "/contacts", `{"username":"Joe"}`); status != 200 {
t.Fatalf("add: %d", status)
}
do(t, srv, "alice", "POST", "/contacts", `{"username":"bob"}`)
if status, _ := do(t, srv, "alice", "POST", "/contacts", `{"username":"alice"}`); status != 400 {
t.Errorf("adding yourself: %d", status)
}
do(t, srv, "joe", "GET", "/poll", "") // joe comes online
_, list := do(t, srv, "alice", "GET", "/contacts", "")
got, _ := json.Marshal(list["contacts"])
want := `[{"online":false,"username":"bob"},{"online":true,"username":"joe"}]`
if string(got) != want {
t.Fatalf("contacts = %s, want %s", got, want)
}
do(t, srv, "alice", "DELETE", "/contacts/bob", "")
_, list = do(t, srv, "alice", "GET", "/contacts", "")
if n := len(list["contacts"].([]any)); n != 1 {
t.Fatalf("after delete: %d contacts", n)
}
// Contact lists are per person.
_, other := do(t, srv, "bob", "GET", "/contacts", "")
if n := len(other["contacts"].([]any)); n != 0 {
t.Fatalf("bob sees %d of alice's contacts", n)
}
}

View file

@ -85,31 +85,3 @@ func (v *Verifier) Verify(ctx context.Context, token string) (string, error) {
// The subject is the Quine username.
return parsed.Subject, nil
}
// authedHandler is a handler that knows who's calling.
type authedHandler func(w http.ResponseWriter, r *http.Request, username string)
// requireAuth rejects requests without a valid Quine access token and passes
// the token's username to h. Usernames always come from the token, never
// from the request, so nobody can act as someone else.
func requireAuth(v *Verifier, h authedHandler) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
token, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ")
if !ok {
w.Header().Set("WWW-Authenticate", "Bearer")
writeError(w, http.StatusUnauthorized, "sign in required")
return
}
username, err := v.Verify(r.Context(), token)
if errors.Is(err, errIdentityUnavailable) {
writeError(w, http.StatusServiceUnavailable, err.Error())
return
}
if err != nil {
w.Header().Set("WWW-Authenticate", `Bearer error="invalid_token"`)
writeError(w, http.StatusUnauthorized, "invalid or expired token")
return
}
h(w, r, username)
}
}

87
server/contacts.go Normal file
View file

@ -0,0 +1,87 @@
package main
import (
"encoding/json"
"errors"
"fmt"
"os"
"slices"
"sync"
)
// maxContacts keeps one account from growing the file without bound.
const maxContacts = 500
// Contacts is each person's contact list (usernames), kept in a JSON file so
// it survives restarts and follows them across devices.
type Contacts struct {
mu sync.Mutex
path string
lists map[string][]string
}
func LoadContacts(path string) (*Contacts, error) {
c := &Contacts{path: path, lists: map[string][]string{}}
raw, err := os.ReadFile(path)
if errors.Is(err, os.ErrNotExist) {
return c, nil
}
if err != nil {
return nil, err
}
if err := json.Unmarshal(raw, &c.lists); err != nil {
return nil, fmt.Errorf("%s: %w", path, err)
}
return c, nil
}
// List returns owner's contacts, sorted.
func (c *Contacts) List(owner string) []string {
c.mu.Lock()
defer c.mu.Unlock()
return slices.Clone(c.lists[owner])
}
// Add puts username on owner's list (no-op if it's already there).
func (c *Contacts) Add(owner, username string) error {
c.mu.Lock()
defer c.mu.Unlock()
list := c.lists[owner]
if slices.Contains(list, username) {
return nil
}
if len(list) >= maxContacts {
return fmt.Errorf("you can have at most %d contacts", maxContacts)
}
list = append(list, username)
slices.Sort(list)
c.lists[owner] = list
return c.saveLocked()
}
// Remove takes username off owner's list.
func (c *Contacts) Remove(owner, username string) error {
c.mu.Lock()
defer c.mu.Unlock()
list := slices.DeleteFunc(c.lists[owner], func(a string) bool { return a == username })
if len(list) == 0 {
delete(c.lists, owner)
} else {
c.lists[owner] = list
}
return c.saveLocked()
}
// saveLocked writes via a temp file + rename, so a crash mid-write can't
// leave a truncated file behind.
func (c *Contacts) saveLocked() error {
raw, err := json.Marshal(c.lists)
if err != nil {
return err
}
tmp := c.path + ".tmp"
if err := os.WriteFile(tmp, raw, 0o600); err != nil {
return err
}
return os.Rename(tmp, c.path)
}

View file

@ -1,7 +1,9 @@
package main
import (
"context"
"encoding/json"
"errors"
"net/http"
"strings"
)
@ -22,7 +24,7 @@ func writeError(w http.ResponseWriter, status int, msg string) {
func withCORS(h http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
@ -32,60 +34,184 @@ func withCORS(h http.HandlerFunc) http.HandlerFunc {
}
}
// handleRegister announces/refreshes the caller's presence. POST
func (s *Store) handleRegister(w http.ResponseWriter, r *http.Request, username string) {
if r.Method != http.MethodPost {
writeError(w, http.StatusMethodNotAllowed, "POST only")
return
}
s.Touch(username)
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
// tokenVerifier checks a Quine access token and returns its subject (the
// caller's username). *Verifier is the real one; tests substitute a fake.
type tokenVerifier interface {
Verify(ctx context.Context, token string) (string, error)
}
// handleUsers lists usernames seen recently, so an app can show who's
// reachable right now. GET
func (s *Store) handleUsers(w http.ResponseWriter, r *http.Request, _ string) {
// App is the signaling service: presence, calls and contacts for everyone
// with a Quine account.
type App struct {
verifier tokenVerifier
store *Store
contacts *Contacts
// defaultJitsiURL hosts calls that don't name a server.
defaultJitsiURL string
}
func (a *App) Routes(mux *http.ServeMux) {
mux.HandleFunc("/me", withCORS(a.requireAuth(a.handleMe)))
mux.HandleFunc("/register", withCORS(a.requireAuth(a.handleRegister)))
mux.HandleFunc("/poll", withCORS(a.requireAuth(a.handlePoll)))
mux.HandleFunc("/call", withCORS(a.requireAuth(a.handleCall)))
mux.HandleFunc("/contacts", withCORS(a.requireAuth(a.handleContacts)))
mux.HandleFunc("/contacts/{username}", withCORS(a.requireAuth(a.handleContact)))
}
// authedHandler is a handler that knows who's calling.
type authedHandler func(w http.ResponseWriter, r *http.Request, me string)
// requireAuth rejects requests without a valid Quine access token and passes
// the token's username to h. Usernames always come from the token, never
// from the request, so nobody can act as someone else.
func (a *App) requireAuth(h authedHandler) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
token, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ")
if !ok {
w.Header().Set("WWW-Authenticate", "Bearer")
writeError(w, http.StatusUnauthorized, "sign in required")
return
}
username, err := a.verifier.Verify(r.Context(), token)
if errors.Is(err, errIdentityUnavailable) {
writeError(w, http.StatusServiceUnavailable, err.Error())
return
}
if err != nil {
w.Header().Set("WWW-Authenticate", `Bearer error="invalid_token"`)
writeError(w, http.StatusUnauthorized, "invalid or expired token")
return
}
h(w, r, username)
}
}
// handleMe tells the app who it's signed in as, and which Jitsi calls use
// unless the caller picks another. GET
func (a *App) handleMe(w http.ResponseWriter, r *http.Request, me string) {
if r.Method != http.MethodGet {
writeError(w, http.StatusMethodNotAllowed, "GET only")
return
}
writeJSON(w, http.StatusOK, map[string][]string{"users": s.OnlineUsers()})
writeJSON(w, http.StatusOK, map[string]string{"username": me, "defaultJitsiUrl": a.defaultJitsiURL})
}
// handleCall starts a call from the caller: mints a meeting ID and queues an
// invite for the callee to discover next time they poll. POST {"to": "bob"}
func (s *Store) handleCall(w http.ResponseWriter, r *http.Request, from string) {
// handleRegister announces/refreshes the caller's presence. POST
func (a *App) handleRegister(w http.ResponseWriter, r *http.Request, me string) {
if r.Method != http.MethodPost {
writeError(w, http.StatusMethodNotAllowed, "POST only")
return
}
a.store.Touch(me)
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
}
// handlePoll returns (and clears) invites waiting for the caller, and counts
// as presence. GET
func (a *App) handlePoll(w http.ResponseWriter, r *http.Request, me string) {
if r.Method != http.MethodGet {
writeError(w, http.StatusMethodNotAllowed, "GET only")
return
}
writeJSON(w, http.StatusOK, map[string][]Invite{"invites": a.store.DrainInvites(me)})
}
// handleCall starts a call from the caller to another user, on whichever
// Jitsi Meet server the caller picks (the default if none). Jitsi servers
// don't need any credentials from us: both sides just open the same room.
// POST {"to": "bob", "jitsiUrl": "https://meet.jit.si"}
func (a *App) handleCall(w http.ResponseWriter, r *http.Request, me string) {
if r.Method != http.MethodPost {
writeError(w, http.StatusMethodNotAllowed, "POST only")
return
}
var body struct {
To string `json:"to"`
To string `json:"to"`
JitsiURL string `json:"jitsiUrl"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON body")
return
}
to := strings.ToLower(strings.TrimSpace(body.To))
if to == "" {
writeError(w, http.StatusBadRequest, "to is required")
to, err := normalizeUsername(body.To)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
if to == from {
if to == me {
writeError(w, http.StatusBadRequest, "you can't call yourself")
return
}
s.Touch(from)
inv := s.PlaceCall(from, to)
writeJSON(w, http.StatusOK, map[string]string{"meetingId": inv.MeetingID})
jitsiURL := a.defaultJitsiURL
if body.JitsiURL != "" {
if jitsiURL, err = normalizeJitsiURL(body.JitsiURL); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
}
a.store.Touch(me)
inv := a.store.PlaceCall(to, Invite{From: me, MeetingID: newMeetingID(), JitsiURL: jitsiURL})
writeJSON(w, http.StatusOK, map[string]string{"meetingId": inv.MeetingID, "jitsiUrl": jitsiURL})
}
// handlePoll returns (and clears) invites waiting for the caller. GET
func (s *Store) handlePoll(w http.ResponseWriter, r *http.Request, username string) {
if r.Method != http.MethodGet {
writeError(w, http.StatusMethodNotAllowed, "GET only")
type contact struct {
Username string `json:"username"`
Online bool `json:"online"`
}
// handleContacts lists the caller's contacts with presence (GET), or adds
// one (POST {"username": "bob"}).
func (a *App) handleContacts(w http.ResponseWriter, r *http.Request, me string) {
switch r.Method {
case http.MethodGet:
list := []contact{}
for _, name := range a.contacts.List(me) {
list = append(list, contact{Username: name, Online: a.store.IsOnline(name)})
}
writeJSON(w, http.StatusOK, map[string][]contact{"contacts": list})
case http.MethodPost:
var body struct {
Username string `json:"username"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON body")
return
}
name, err := normalizeUsername(body.Username)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
if name == me {
writeError(w, http.StatusBadRequest, "that's you")
return
}
if err := a.contacts.Add(me, name); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, contact{Username: name, Online: a.store.IsOnline(name)})
default:
writeError(w, http.StatusMethodNotAllowed, "GET or POST only")
}
}
// handleContact removes one contact. DELETE /contacts/{username}
func (a *App) handleContact(w http.ResponseWriter, r *http.Request, me string) {
if r.Method != http.MethodDelete {
writeError(w, http.StatusMethodNotAllowed, "DELETE only")
return
}
invites := s.DrainInvites(username)
writeJSON(w, http.StatusOK, map[string][]Invite{"invites": invites})
name, err := normalizeUsername(r.PathValue("username"))
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
if err := a.contacts.Remove(me, name); err != nil {
writeError(w, http.StatusInternalServerError, "could not save contacts")
return
}
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
}

View file

@ -1,50 +1,67 @@
// Command quple-server is the minimal central rendezvous service for Quple.
// Command quple-server is the central rendezvous service for Quple
// (meet.quineglobal.com).
//
// It does barely more than the job requires: signed-in clients announce
// themselves, look up who else is online, place a call (which mints a meeting
// ID), and poll for calls placed to them. Every request carries a Quine access
// token (from identity.quineglobal.com), which is where the caller's username
// comes from. Nothing here is a source of truth
// beyond "who has said hello recently" and "what calls are outstanding" -
// the actual meeting (media, chat, etc.) happens entirely inside Jitsi Meet,
// which this server never talks to.
// Everyone signs in with Quine (identity.quineglobal.com), the one identity
// server, and is known by their Quine username. Signed-in clients announce
// themselves, keep a contact list, place calls, and poll for calls placed to
// them. A call names the Jitsi Meet server it happens on - any one, picked by
// the caller - and this server just tells the callee where to go. The meeting
// itself (media, chat, etc.) happens entirely inside Jitsi Meet, which this
// server never talks to.
//
// Configuration (environment):
//
// ADDR listen address (default :8080)
// ISSUER Quine identity server whose access tokens are accepted
// (default https://identity.quineglobal.com)
// CLIENT_ID app the tokens must be issued to (default quple)
// DEFAULT_JITSI Jitsi Meet server for calls that don't name one (default https://meet.jit.si)
// DATA_DIR contacts (default ./data)
package main
import (
"log"
"net/http"
"os"
"path/filepath"
)
func main() {
addr := os.Getenv("ADDR")
if addr == "" {
addr = ":8080"
addr := envOr("ADDR", ":8080")
issuer := envOr("ISSUER", "https://identity.quineglobal.com")
dataDir := envOr("DATA_DIR", "data")
defaultJitsi, err := normalizeJitsiURL(envOr("DEFAULT_JITSI", "https://meet.jit.si"))
if err != nil {
log.Fatalf("DEFAULT_JITSI: %v", err)
}
// ISSUER is the Quine identity server whose access tokens are accepted;
// CLIENT_ID is the app they must have been issued to.
issuer := os.Getenv("ISSUER")
if issuer == "" {
issuer = "https://identity.quineglobal.com"
if err := os.MkdirAll(dataDir, 0o700); err != nil {
log.Fatal(err)
}
clientID := os.Getenv("CLIENT_ID")
if clientID == "" {
clientID = "quple"
contacts, err := LoadContacts(filepath.Join(dataDir, "contacts.json"))
if err != nil {
log.Fatal(err)
}
verifier := NewVerifier(issuer, clientID)
store := NewStore()
app := &App{
verifier: NewVerifier(issuer, envOr("CLIENT_ID", "quple")),
store: NewStore(),
contacts: contacts,
defaultJitsiURL: defaultJitsi,
}
mux := http.NewServeMux()
mux.HandleFunc("/register", withCORS(requireAuth(verifier, store.handleRegister)))
mux.HandleFunc("/users", withCORS(requireAuth(verifier, store.handleUsers)))
mux.HandleFunc("/call", withCORS(requireAuth(verifier, store.handleCall)))
mux.HandleFunc("/poll", withCORS(requireAuth(verifier, store.handlePoll)))
app.Routes(mux)
mux.HandleFunc("/healthz", withCORS(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("ok"))
}))
log.Printf("quple-server listening on %s, accepting tokens from %s", addr, issuer)
log.Printf("quple-server listening on %s, accepting tokens from %s, default Jitsi %s", addr, issuer, defaultJitsi)
log.Fatal(http.ListenAndServe(addr, mux))
}
func envOr(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}

38
server/names.go Normal file
View file

@ -0,0 +1,38 @@
package main
import (
"errors"
"net/url"
"regexp"
"strings"
)
// usernamePattern matches the identity server's: usernames are what people
// type to call each other, and they end up in URLs and meeting display names.
var usernamePattern = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,31}$`)
// normalizeUsername lowercases s and checks it's a well-formed Quine username.
func normalizeUsername(s string) (string, error) {
s = strings.ToLower(strings.TrimSpace(s))
if !usernamePattern.MatchString(s) {
return "", errors.New("a username is 1-32 chars of a-z, 0-9, '.', '_' or '-', starting with a letter or digit")
}
return s, nil
}
// normalizeJitsiURL checks s is the base URL of a Jitsi Meet deployment, like
// https://meet.jit.si, and returns it without a trailing slash. Any server
// will do - the callee's app opens it, so it only has to be a plain http(s)
// URL that can't smuggle anything else along.
func normalizeJitsiURL(s string) (string, error) {
s = strings.TrimRight(strings.TrimSpace(s), "/")
if !strings.Contains(s, "://") {
s = "https://" + s
}
u, err := url.Parse(s)
if err != nil || (u.Scheme != "https" && u.Scheme != "http") || u.Hostname() == "" ||
u.User != nil || u.RawQuery != "" || u.Fragment != "" || len(s) > 200 {
return "", errors.New("the server must be a Jitsi Meet URL, like https://meet.jit.si")
}
return u.Scheme + "://" + strings.ToLower(u.Host) + u.EscapedPath(), nil
}

View file

@ -3,7 +3,6 @@ package main
import (
"crypto/rand"
"encoding/hex"
"sort"
"sync"
"time"
)
@ -11,8 +10,10 @@ import (
// Invite is a pending call: someone tried to reach a user who hasn't
// picked up (polled it) yet.
type Invite struct {
From string `json:"from"`
MeetingID string `json:"meetingId"`
From string `json:"from"`
MeetingID string `json:"meetingId"`
// JitsiURL is the Jitsi Meet server the caller picked for this call.
JitsiURL string `json:"jitsiUrl"`
CreatedAt time.Time `json:"createdAt"`
}
@ -48,25 +49,17 @@ func (s *Store) Touch(username string) {
s.lastSeen[username] = time.Now()
}
// OnlineUsers returns usernames seen within presenceWindow, sorted.
func (s *Store) OnlineUsers() []string {
// IsOnline reports whether username was seen within presenceWindow.
func (s *Store) IsOnline(username string) bool {
s.mu.Lock()
defer s.mu.Unlock()
cutoff := time.Now().Add(-presenceWindow)
users := make([]string, 0, len(s.lastSeen))
for u, t := range s.lastSeen {
if t.After(cutoff) {
users = append(users, u)
}
}
sort.Strings(users)
return users
t, ok := s.lastSeen[username]
return ok && time.Since(t) < presenceWindow
}
// PlaceCall records an invite from -> to and returns it, including the
// freshly minted meeting ID the two sides will rendezvous on.
func (s *Store) PlaceCall(from, to string) Invite {
inv := Invite{From: from, MeetingID: newMeetingID(), CreatedAt: time.Now()}
// PlaceCall queues inv for to, stamping its creation time.
func (s *Store) PlaceCall(to string, inv Invite) Invite {
inv.CreatedAt = time.Now()
s.mu.Lock()
defer s.mu.Unlock()
s.invites[to] = append(s.invites[to], inv)

View file

@ -5,59 +5,35 @@ import "testing"
func TestPlaceCallAndDrainInvites(t *testing.T) {
s := NewStore()
inv := s.PlaceCall("alice", "bob")
if inv.From != "alice" {
t.Fatalf("From = %q, want alice", inv.From)
}
if inv.MeetingID == "" {
t.Fatal("MeetingID is empty")
inv := s.PlaceCall("bob", Invite{From: "alice", MeetingID: newMeetingID()})
if inv.CreatedAt.IsZero() {
t.Fatal("CreatedAt not set")
}
got := s.DrainInvites("bob")
if len(got) != 1 || got[0].MeetingID != inv.MeetingID {
t.Fatalf("DrainInvites(bob) = %+v, want one invite with meetingId %q", got, inv.MeetingID)
t.Fatalf("DrainInvites = %+v, want one invite with meetingId %q", got, inv.MeetingID)
}
// Second drain should be empty: invites are delivered once.
got = s.DrainInvites("bob")
if len(got) != 0 {
t.Fatalf("DrainInvites(bob) after drain = %+v, want empty", got)
if got := s.DrainInvites("bob"); len(got) != 0 {
t.Fatalf("DrainInvites after drain = %+v, want empty", got)
}
}
func TestPlaceCallGeneratesDistinctMeetingIDs(t *testing.T) {
func TestMeetingIDsAreDistinct(t *testing.T) {
if newMeetingID() == newMeetingID() {
t.Fatal("two meeting IDs collided")
}
}
func TestPresence(t *testing.T) {
s := NewStore()
a := s.PlaceCall("alice", "bob")
b := s.PlaceCall("alice", "bob")
if a.MeetingID == b.MeetingID {
t.Fatalf("two calls minted the same meeting ID: %q", a.MeetingID)
}
}
func TestOnlineUsers(t *testing.T) {
s := NewStore()
s.Touch("bob")
s.Touch("alice")
users := s.OnlineUsers()
if len(users) != 2 || users[0] != "alice" || users[1] != "bob" {
t.Fatalf("OnlineUsers() = %v, want [alice bob]", users)
}
}
func TestDrainInvitesTouchesPresence(t *testing.T) {
s := NewStore()
s.DrainInvites("bob")
users := s.OnlineUsers()
if len(users) != 1 || users[0] != "bob" {
t.Fatalf("OnlineUsers() after poll = %v, want [bob]", users)
}
}
func TestOnlineUsersExcludesUnknown(t *testing.T) {
s := NewStore()
if users := s.OnlineUsers(); len(users) != 0 {
t.Fatalf("OnlineUsers() on empty store = %v, want []", users)
if s.IsOnline("bob") {
t.Fatal("unknown user reported online")
}
s.DrainInvites("bob") // polling counts as presence
if !s.IsOnline("bob") {
t.Fatal("polling user not reported online")
}
}