quple/server/store.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

94 lines
2.6 KiB
Go

package main
import (
"crypto/rand"
"encoding/hex"
"sync"
"time"
)
// 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"`
// JitsiURL is the Jitsi Meet server the caller picked for this call.
JitsiURL string `json:"jitsiUrl"`
CreatedAt time.Time `json:"createdAt"`
}
// presenceWindow is how long a user is considered "online" after their last
// /register or /poll call. Clients are expected to hit one of those every
// few seconds, so this just needs to comfortably outlast that interval.
const presenceWindow = 90 * time.Second
// inviteTTL bounds how long an undelivered invite is kept around. If the
// callee's app isn't polling (closed, offline, etc.) the invite is dropped
// rather than kept forever.
const inviteTTL = 2 * time.Minute
// Store is a deliberately minimal, in-memory, process-lifetime store with no
// persistence. Accounts live in the Quine identity server; usernames here are
// whatever verified access tokens said (see auth.go).
type Store struct {
mu sync.Mutex
lastSeen map[string]time.Time
invites map[string][]Invite
}
func NewStore() *Store {
return &Store{
lastSeen: make(map[string]time.Time),
invites: make(map[string][]Invite),
}
}
func (s *Store) Touch(username string) {
s.mu.Lock()
defer s.mu.Unlock()
s.lastSeen[username] = time.Now()
}
// IsOnline reports whether username was seen within presenceWindow.
func (s *Store) IsOnline(username string) bool {
s.mu.Lock()
defer s.mu.Unlock()
t, ok := s.lastSeen[username]
return ok && time.Since(t) < presenceWindow
}
// 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)
return inv
}
// DrainInvites returns and clears every pending invite for username,
// filtering out anything older than inviteTTL. Polling also counts as
// presence, so it touches lastSeen too.
func (s *Store) DrainInvites(username string) []Invite {
s.mu.Lock()
defer s.mu.Unlock()
s.lastSeen[username] = time.Now()
pending := s.invites[username]
delete(s.invites, username)
cutoff := time.Now().Add(-inviteTTL)
fresh := make([]Invite, 0, len(pending))
for _, inv := range pending {
if inv.CreatedAt.After(cutoff) {
fresh = append(fresh, inv)
}
}
return fresh
}
func newMeetingID() string {
buf := make([]byte, 8)
_, _ = rand.Read(buf)
return "quple-" + hex.EncodeToString(buf)
}