Files
Commilitia-Drop/desktop/platform/heal.go
T

105 lines
4.1 KiB
Go

package platform
import (
"context"
"encoding/json"
"log/slog"
"net/http"
"strings"
"time"
)
// HealIdentity refreshes a persisted session's display identity (name + avatar) from the
// authoritative /api/me and re-persists it, BEFORE the session is injected into the WebView.
//
// Why: a session minted by an older build derived identity from the broker's nameless machine
// token, baking the subject UUID (and no avatar) into the on-disk session. The newer login path
// records the real name/picture, but an already-logged-in user keeps the stale session. Correcting
// it only in the WebView (per launch, via /api/me) is fragile — when the boot access token has
// expired and the refresh is momentarily flaky, the correction silently fails and the UUID shows
// again (the reported intermittent regression). Healing the persisted session here fixes it at the
// source: once a single launch succeeds, the on-disk identity is correct and every later boot is
// correct without any /api/me dependency.
//
// Synchronous and bounded so it can run before wails.Run; best-effort — on any network failure it
// returns the session unchanged (offline simply keeps whatever was last persisted, which a prior
// successful heal already corrected). If the access token has expired it refreshes once (and the
// rotated refresh token is persisted so it is not lost).
func HealIdentity(s *LoginResult, apiBase string) *LoginResult {
if s == nil || s.AccessToken == "" {
return s
}
// Once healed, the persisted name is a real display name (not the subject UUID); skip the
// /api/me round-trip then so it costs nothing on every later launch. A stale session (name
// still equal to the UUID) falls through and heals — fetching the avatar in the same pass.
if s.User.Name != "" && s.User.Name != s.User.ID {
return s
}
ctx, cancel := context.WithTimeout(context.Background(), 6*time.Second)
defer cancel()
info, status := fetchMe(ctx, apiBase, s.AccessToken)
tokenRotated := false
if status == http.StatusUnauthorized && s.RefreshToken != "" {
if cfg, err := FetchOAuthConfig(ctx, apiBase); err == nil {
if tok, rerr := NewFlow(cfg, func(string) {}).Refresh(ctx, s.RefreshToken); rerr == nil && tok.AccessToken != "" {
s.AccessToken = tok.AccessToken
if tok.RefreshToken != "" {
s.RefreshToken = tok.RefreshToken // broker rotates refresh; the old one is now dead
}
s.ExpiresIn = tok.ExpiresIn
tokenRotated = true
info, status = fetchMe(ctx, apiBase, s.AccessToken)
}
}
}
changed := tokenRotated // a rotated refresh token must be persisted, regardless of the heal outcome
if status == http.StatusOK && info.ID != "" {
// Replace a stale name only with a real display name (not the subject UUID).
if info.Name != "" && info.Name != info.ID && info.Name != s.User.Name {
s.User.Name = info.Name
changed = true
}
if info.Avatar != "" && info.Avatar != s.User.Avatar {
s.User.Avatar = info.Avatar
changed = true
}
}
if changed {
if err := SaveSession(*s); err != nil {
slog.Warn("Commilitia Drop: heal identity save failed", "err", err)
}
}
return s
}
// fetchMe GETs /api/me with the access token, returning the identity and HTTP status (0 on a
// transport error). It hits the public origin directly (no WebView proxy needed here).
func fetchMe(ctx context.Context, apiBase, accessToken string) (UserInfo, int) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, strings.TrimRight(apiBase, "/")+"/api/me", nil)
if err != nil {
return UserInfo{}, 0
}
req.Header.Set("Authorization", "Bearer "+accessToken)
req.Header.Set("Accept", "application/json")
resp, err := (&http.Client{Timeout: 10 * time.Second}).Do(req)
if err != nil {
return UserInfo{}, 0
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return UserInfo{}, resp.StatusCode
}
var m struct {
UserID string `json:"user_id"`
Name string `json:"name"`
Avatar string `json:"avatar"`
}
if err := json.NewDecoder(resp.Body).Decode(&m); err != nil {
return UserInfo{}, 0
}
return UserInfo{ID: m.UserID, Name: m.Name, Avatar: m.Avatar}, http.StatusOK
}