| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "encoding/json" |
| 5 | "fmt" |
| 6 | "net/url" |
| 7 | "os" |
| 8 | "path/filepath" |
| 9 | "sort" |
| 10 | "strings" |
| 11 | |
| 12 | "lchat/internal/client" |
| 13 | ) |
| 14 | |
| 15 | // The session store: ~/.eleven holds one session file per space plus the home |
| 16 | // sign-in. Every file is a private key in JSON clothing, hence 0600/0700. |
| 17 | // |
| 18 | // ~/.eleven/spaces/<host>.json one client.Session per space |
| 19 | // ~/.eleven/home.json the home vault session (token + blob key) |
| 20 | // |
| 21 | // A -session flag bypasses the store entirely (single-space mode, for scripts |
| 22 | // pinning a specific identity file) — that path never touches this type. |
| 23 | type store struct { |
| 24 | dir string |
| 25 | } |
| 26 | |
| 27 | func openStore(dir string) (*store, error) { |
| 28 | st := &store{dir: dir} |
| 29 | if err := os.MkdirAll(filepath.Join(dir, "spaces"), 0o700); err != nil { |
| 30 | return nil, err |
| 31 | } |
| 32 | if err := st.migrateLegacy(); err != nil { |
| 33 | return nil, err |
| 34 | } |
| 35 | return st, nil |
| 36 | } |
| 37 | |
| 38 | // migrateLegacy moves a pre-multi-space ~/.eleven/session.json into spaces/. |
| 39 | func (st *store) migrateLegacy() error { |
| 40 | legacy := filepath.Join(st.dir, "session.json") |
| 41 | sess, err := client.LoadSession(legacy) |
| 42 | if err != nil || sess == nil { |
| 43 | return err |
| 44 | } |
| 45 | if sess.Base == "" { |
| 46 | return fmt.Errorf("%s has no server URL — re-join with `eleven join`", legacy) |
| 47 | } |
| 48 | if err := st.saveSpace(sess); err != nil { |
| 49 | return err |
| 50 | } |
| 51 | if err := os.Remove(legacy); err != nil { |
| 52 | return err |
| 53 | } |
| 54 | fmt.Fprintf(os.Stderr, "(Moved %s into %s.)\n", legacy, filepath.Join(st.dir, "spaces")) |
| 55 | return nil |
| 56 | } |
| 57 | |
| 58 | func (st *store) spacePath(sess *client.Session) (string, error) { |
| 59 | u, err := url.Parse(sess.Base) |
| 60 | if err != nil || u.Host == "" { |
| 61 | return "", fmt.Errorf("bad space URL %q", sess.Base) |
| 62 | } |
| 63 | return filepath.Join(st.dir, "spaces", u.Host+".json"), nil |
| 64 | } |
| 65 | |
| 66 | func (st *store) saveSpace(sess *client.Session) error { |
| 67 | path, err := st.spacePath(sess) |
| 68 | if err != nil { |
| 69 | return err |
| 70 | } |
| 71 | return sess.Save(path) |
| 72 | } |
| 73 | |
| 74 | // spaces loads every saved space, hosts alphabetical (stable for display and |
| 75 | // for "first match wins" resolution). |
| 76 | func (st *store) spaces() ([]*client.Session, error) { |
| 77 | glob, err := filepath.Glob(filepath.Join(st.dir, "spaces", "*.json")) |
| 78 | if err != nil { |
| 79 | return nil, err |
| 80 | } |
| 81 | sort.Strings(glob) |
| 82 | var out []*client.Session |
| 83 | for _, p := range glob { |
| 84 | sess, err := client.LoadSession(p) |
| 85 | if err != nil { |
| 86 | fmt.Fprintf(os.Stderr, "eleven: skipping %s: %v\n", p, err) |
| 87 | continue |
| 88 | } |
| 89 | if sess != nil && sess.Base != "" { |
| 90 | out = append(out, sess) |
| 91 | } |
| 92 | } |
| 93 | return out, nil |
| 94 | } |
| 95 | |
| 96 | func (st *store) homePath() string { return filepath.Join(st.dir, "home.json") } |
| 97 | |
| 98 | // homeSession loads the saved home sign-in, nil when there is none. |
| 99 | func (st *store) homeSession() (*client.HomeSession, error) { |
| 100 | raw, err := os.ReadFile(st.homePath()) |
| 101 | if os.IsNotExist(err) { |
| 102 | return nil, nil |
| 103 | } |
| 104 | if err != nil { |
| 105 | return nil, err |
| 106 | } |
| 107 | var hs client.HomeSession |
| 108 | if err := json.Unmarshal(raw, &hs); err != nil { |
| 109 | return nil, fmt.Errorf("bad home session file %s: %w", st.homePath(), err) |
| 110 | } |
| 111 | if hs.Token == "" || hs.DEK == "" { |
| 112 | return nil, fmt.Errorf("home session file %s is missing its session", st.homePath()) |
| 113 | } |
| 114 | return &hs, nil |
| 115 | } |
| 116 | |
| 117 | func (st *store) saveHomeSession(hs *client.HomeSession) error { |
| 118 | raw, err := json.MarshalIndent(hs, "", " ") |
| 119 | if err != nil { |
| 120 | return err |
| 121 | } |
| 122 | return os.WriteFile(st.homePath(), append(raw, '\n'), 0o600) |
| 123 | } |
| 124 | |
| 125 | // spaceLabel names a space for display: its vault name when the backup had |
| 126 | // one, else its host. |
| 127 | func spaceLabel(sess *client.Session) string { |
| 128 | if sess.SpaceName != "" { |
| 129 | return sess.SpaceName |
| 130 | } |
| 131 | if u, err := url.Parse(sess.Base); err == nil && u.Host != "" { |
| 132 | return u.Host |
| 133 | } |
| 134 | return sess.Base |
| 135 | } |
| 136 | |
| 137 | // spaceHost is the space's host, for the space/chat needle syntax. |
| 138 | func spaceHost(sess *client.Session) string { |
| 139 | if u, err := url.Parse(sess.Base); err == nil { |
| 140 | return u.Host |
| 141 | } |
| 142 | return sess.Base |
| 143 | } |
| 144 | |
| 145 | func matchesSpace(sess *client.Session, needle string) bool { |
| 146 | needle = strings.ToLower(needle) |
| 147 | return strings.Contains(strings.ToLower(spaceHost(sess)), needle) || |
| 148 | strings.Contains(strings.ToLower(sess.SpaceName), needle) |
| 149 | } |
| 150 | |