quple/server/handlers.go
Philip Peterson 96f7134c51 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>
2026-10-09 05:37:31 -08:00

217 lines
7.1 KiB
Go

package main
import (
"context"
"encoding/json"
"errors"
"net/http"
"strings"
)
func writeJSON(w http.ResponseWriter, status int, body any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(body)
}
func writeError(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, map[string]string{"error": msg})
}
// withCORS allows any origin: the desktop webview calls this server as a
// cross-origin fetch, and auth is a bearer token rather than cookies, so
// there's no ambient credential for another origin to ride on.
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, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
h(w, r)
}
}
// 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)
}
// 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{"username": me, "defaultJitsiUrl": a.defaultJitsiURL})
}
// 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"`
JitsiURL string `json:"jitsiUrl"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON body")
return
}
to, err := normalizeUsername(body.To)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
if to == me {
writeError(w, http.StatusBadRequest, "you can't call yourself")
return
}
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})
}
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
}
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})
}