package client import ( "bytes" "cmp" "context" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "os" "path/filepath" "slices" "strings" "time" "lodge/internal/protocol" ) const requestTimeout = 15 * time.Second var ( ErrNotLoggedIn = errors.New("not logged in: run the install command from the lodge admin page") errConflict = errors.New("server state changed") ) type Client struct { URL string `json:"url"` Token string `json:"token"` DataDir string `json:"-"` } 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 Load() (*Client, error) { c := &Client{DataDir: dataDir()} if data, err := os.ReadFile(configPath()); err == nil { if err := json.Unmarshal(data, c); err != nil { return nil, err } } c.URL = strings.TrimSuffix(cmp.Or(os.Getenv("LODGE_URL"), c.URL), "/") c.Token = cmp.Or(os.Getenv("LODGE_TOKEN"), c.Token) if c.URL == "" || c.Token == "" { return nil, ErrNotLoggedIn } return c, nil } func Login(ctx context.Context, serverURL, code string) error { name, _ := os.Hostname() c := &Client{URL: strings.TrimSuffix(serverURL, "/")} var claim protocol.ClaimResponse if err := c.call(ctx, http.MethodPost, "/api/claim", protocol.ClaimRequest{Code: code, Name: name}, &claim); err != nil { return err } c.Token = claim.Token return writeJSONFile(configPath(), c) } 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 (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 := http.DefaultClient.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 (c *Client) registryPath() string { return filepath.Join(c.DataDir, "worktrees.json") } func (c *Client) Worktrees() []string { var roots []string if data, err := os.ReadFile(c.registryPath()); err == nil { _ = json.Unmarshal(data, &roots) } return roots } func (c *Client) register(root string) error { roots := c.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(c.registryPath(), append(roots, root)) } func (c *Client) Forget(root string) error { roots := slices.DeleteFunc(c.Worktrees(), func(known string) bool { return known == root }) return writeJSONFile(c.registryPath(), roots) }