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>
94 lines
2.6 KiB
Go
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)
|
|
}
|