package client import ( "bytes" "cmp" "context" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "fmt" "io" "maps" "net/http" "net/url" "os" "path/filepath" "slices" "strings" "time" "lodge/internal/protocol" ) const ( requestTimeout = 15 * time.Second sendPingAfter = 15 * time.Second pingTimeout = 10 * time.Second ) var ( httpClient = &http.Client{Transport: newTransport()} ErrNotLoggedIn = errors.New("not logged in: run the install command from the lodge admin page, or lodge remote inside the repository") errConflict = errors.New("server state changed") ) type Config struct { URL string `json:"url,omitempty"` Token string `json:"token,omitempty"` Servers map[string]string `json:"servers,omitempty"` Repos map[string]string `json:"repos,omitempty"` DataDir string `json:"-"` path string } type Client struct { URL string Token string DataDir string } func configPath() string { home, _ := os.UserHomeDir() return filepath.Join(cmp.Or(os.Getenv("XDG_CONFIG_HOME"), filepath.Join(home, ".config")), "lodge", "config.json") } func dataDir() string { home, _ := os.UserHomeDir() return filepath.Join(cmp.Or(os.Getenv("XDG_DATA_HOME"), filepath.Join(home, ".local", "share")), "lodge") } func serverURL(raw string) string { return strings.TrimSuffix(raw, "/") } func Load() (*Config, error) { return loadConfig(configPath(), dataDir()) } func loadConfig(file, dir string) (*Config, error) { cfg := &Config{DataDir: dir, path: file} if data, err := os.ReadFile(file); err == nil { if err := json.Unmarshal(data, cfg); err != nil { return nil, err } } cfg.URL = serverURL(cmp.Or(os.Getenv("LODGE_URL"), cfg.URL)) cfg.Token = cmp.Or(os.Getenv("LODGE_TOKEN"), cfg.Token) return cfg, nil } func (cfg *Config) reload() (*Config, error) { return loadConfig(cfg.path, cfg.DataDir) } func (cfg *Config) update(change func(*Config)) error { unlock, err := flock(cfg.path + ".lock") if err != nil { return err } defer unlock() saved := &Config{Servers: map[string]string{}, Repos: map[string]string{}} if data, err := os.ReadFile(cfg.path); err == nil { if err := json.Unmarshal(data, saved); err != nil { return err } } change(saved) if err := writeJSONFile(cfg.path, saved); err != nil { return err } fresh, err := cfg.reload() if err == nil { *cfg = *fresh } return err } func (cfg *Config) serverOf(key string) string { return cmp.Or(cfg.Repos[key], cfg.URL) } func (cfg *Config) Bound(key string) bool { return cfg.Repos[key] != "" } func (cfg *Config) Server(key string) (*Client, error) { return cfg.client(cfg.serverOf(key)) } func (cfg *Config) client(server string) (*Client, error) { token := cfg.Servers[server] if server == cfg.URL { token = cmp.Or(cfg.Token, token) } switch { case server == "": return nil, ErrNotLoggedIn case token == "": return nil, fmt.Errorf("no token for %s: run lodge remote %s with a code from that server", server, server) } return &Client{URL: server, Token: token, DataDir: cfg.DataDir}, nil } func (cfg *Config) clients() map[Client]bool { clients := map[Client]bool{} for _, server := range append(slices.Collect(maps.Keys(cfg.Servers)), cfg.URL) { if c, err := cfg.client(server); err == nil { clients[*c] = true } } return clients } func claim(ctx context.Context, server, code string) (string, error) { name, _ := os.Hostname() var res protocol.ClaimResponse err := (&Client{URL: server}).call(ctx, http.MethodPost, "/api/claim", protocol.ClaimRequest{Code: code, Name: name}, &res) return res.Token, err } func (cfg *Config) Login(ctx context.Context, server, code string) error { server = serverURL(server) token, err := claim(ctx, server, code) if err != nil { return err } return cfg.update(func(saved *Config) { saved.URL, saved.Token = server, token }) } func (cfg *Config) Connect(ctx context.Context, server, code string) error { server = serverURL(server) token, err := claim(ctx, server, code) if err != nil { return err } return cfg.update(func(saved *Config) { saved.Servers[server] = token }) } func (cfg *Config) Bind(key, server string) error { server = serverURL(server) if server != "" { if _, err := cfg.client(server); err != nil { return err } } return cfg.update(func(saved *Config) { if server == "" { delete(saved.Repos, key) } else { saved.Repos[key] = server } }) } func writeJSONFile(path string, value any) error { data, err := json.MarshalIndent(value, "", " ") if err != nil { return err } return writeFile(path, data, 0o600) } func writeFile(path string, data []byte, mode os.FileMode) error { if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { return err } temp := path + ".lodge-tmp" if err := os.WriteFile(temp, data, mode); err != nil { return err } return os.Rename(temp, path) } func newTransport() *http.Transport { transport := http.DefaultTransport.(*http.Transport).Clone() transport.HTTP2 = &http.HTTP2Config{SendPingTimeout: sendPingAfter, PingTimeout: pingTimeout} return transport } func (c *Client) request(ctx context.Context, method, path string, body []byte) (*http.Response, error) { req, err := http.NewRequestWithContext(ctx, method, c.URL+path, bytes.NewReader(body)) if err != nil { return nil, err } req.Header.Set("Authorization", "Bearer "+c.Token) res, err := httpClient.Do(req) if err != nil { return nil, err } if res.StatusCode < http.StatusBadRequest { return res, nil } defer res.Body.Close() message, _ := io.ReadAll(io.LimitReader(res.Body, 1024)) if res.StatusCode == http.StatusConflict { return nil, errConflict } return nil, fmt.Errorf("%s %s: %s: %s", method, path, res.Status, strings.TrimSpace(string(message))) } func (c *Client) call(ctx context.Context, method, path string, in, out any) error { ctx, cancel := context.WithTimeout(ctx, requestTimeout) defer cancel() var body []byte if in != nil { body, _ = json.Marshal(in) } res, err := c.request(ctx, method, path, body) if err != nil { return err } defer res.Body.Close() if out == nil { return nil } return json.NewDecoder(res.Body).Decode(out) } func (c *Client) State(ctx context.Context, key, at string) (protocol.State, error) { var state protocol.State err := c.call(ctx, http.MethodGet, "/api/repos/"+key+"/state?at="+url.QueryEscape(at), nil, &state) return state, err } func (c *Client) Revisions(ctx context.Context, key, path string) ([]protocol.Revision, error) { var revisions []protocol.Revision err := c.call(ctx, http.MethodGet, "/api/repos/"+key+"/revisions?path="+url.QueryEscape(path), nil, &revisions) return revisions, err } func hashOf(data []byte) string { sum := sha256.Sum256(data) return hex.EncodeToString(sum[:]) } func (c *Client) blobPath(hash string) string { return filepath.Join(c.DataDir, "blobs", hash[:2], hash) } func (c *Client) cacheBlob(data []byte) (string, error) { hash := hashOf(data) path := c.blobPath(hash) if _, err := os.Stat(path); err == nil { return hash, nil } return hash, writeFile(path, data, 0o600) } func (c *Client) Blob(ctx context.Context, hash string) ([]byte, error) { if data, err := os.ReadFile(c.blobPath(hash)); err == nil { return data, nil } ctx, cancel := context.WithTimeout(ctx, requestTimeout) defer cancel() res, err := c.request(ctx, http.MethodGet, "/api/blobs/"+hash, nil) if err != nil { return nil, err } defer res.Body.Close() data, err := io.ReadAll(res.Body) if err != nil { return nil, err } if hashOf(data) != hash { return nil, fmt.Errorf("blob %s arrived corrupted", hash) } _, err = c.cacheBlob(data) return data, err } func (c *Client) uploadBlob(ctx context.Context, hash string) error { data, err := os.ReadFile(c.blobPath(hash)) if err != nil { return err } ctx, cancel := context.WithTimeout(ctx, requestTimeout) defer cancel() res, err := c.request(ctx, http.MethodPut, "/api/blobs/"+hash, data) if err != nil { return err } return res.Body.Close() } func (cfg *Config) registryPath() string { return filepath.Join(cfg.DataDir, "worktrees.json") } func (cfg *Config) Worktrees() []string { var roots []string if data, err := os.ReadFile(cfg.registryPath()); err == nil { _ = json.Unmarshal(data, &roots) } return roots } func (cfg *Config) register(root string) error { roots := cfg.Worktrees() if slices.Contains(roots, root) { return nil } roots = slices.DeleteFunc(roots, func(known string) bool { _, err := os.Stat(known) return err != nil }) return writeJSONFile(cfg.registryPath(), append(roots, root)) } func (cfg *Config) Forget(root string) error { roots := slices.DeleteFunc(cfg.Worktrees(), func(known string) bool { return known == root }) return writeJSONFile(cfg.registryPath(), roots) }