344 lines
8.7 KiB
Go
344 lines
8.7 KiB
Go
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 <url> <code> 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 <code> 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)
|
|
}
|