verstak/internal/core/sync/client.go

347 lines
9.0 KiB
Go

package sync
import (
"bytes"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"os"
"path/filepath"
"time"
)
// Client communicates with the Verstak Sync Server.
type Client struct {
ServerURL string
APIKey string // legacy API key
DeviceToken string // new device token
DeviceID string
VaultRoot string
HTTP *http.Client
}
// NewClient creates a sync client.
func NewClient(serverURL, apiKey, deviceID, vaultRoot string) *Client {
return &Client{
ServerURL: serverURL,
APIKey: apiKey,
DeviceID: deviceID,
VaultRoot: vaultRoot,
HTTP: &http.Client{Timeout: 30 * time.Second},
}
}
// PairDevice calls POST /api/client/pair and returns device_id and device_token.
func (c *Client) PairDevice(serverURL, username, password, deviceName, clientVersion string) (deviceID, deviceToken string, err error) {
body := map[string]string{
"login": username,
"password": password,
"device_name": deviceName,
"client_version": clientVersion,
}
var resp struct {
DeviceID string `json:"device_id"`
DeviceToken string `json:"device_token"`
}
savedURL := c.ServerURL
c.ServerURL = serverURL
err = c.post("/api/client/pair", body, &resp)
c.ServerURL = savedURL
if err != nil {
return "", "", err
}
return resp.DeviceID, resp.DeviceToken, nil
}
// GetMe calls GET /api/client/me and returns device info.
type DeviceInfo struct {
DeviceID string `json:"device_id"`
UserID string `json:"user_id"`
Username string `json:"username"`
DeviceName string `json:"device_name"`
ClientVersion string `json:"client_version"`
LastSeen string `json:"last_seen"`
RevokedAt string `json:"revoked_at"`
CreatedAt string `json:"created_at"`
}
func (c *Client) GetMe() (*DeviceInfo, error) {
var resp DeviceInfo
if err := c.get("/api/client/me", &resp); err != nil {
return nil, err
}
return &resp, nil
}
// RevokeCurrent calls POST /api/client/revoke-current.
func (c *Client) RevokeCurrent() error {
var resp struct {
Status string `json:"status"`
}
return c.post("/api/client/revoke-current", nil, &resp)
}
// RegisterDevice calls POST /api/v1/device/register and returns the API key.
func (c *Client) RegisterDevice(name string) (apiKey string, err error) {
body := map[string]string{"name": name}
var resp struct {
DeviceID string `json:"device_id"`
APIKey string `json:"api_key"`
}
if err := c.post("/api/v1/device/register", body, &resp); err != nil {
return "", err
}
return resp.APIKey, nil
}
// RegisterDeviceWithAuth registers a device with user credentials.
func (c *Client) RegisterDeviceWithAuth(name, username, password string) (deviceID, apiKey string, err error) {
body := map[string]string{"name": name, "username": username, "password": password}
var resp struct {
DeviceID string `json:"device_id"`
APIKey string `json:"api_key"`
}
// Temporarily clear API key for this request (server expects login/password, not API key).
savedKey := c.APIKey
c.APIKey = ""
err = c.post("/api/v1/device/register", body, &resp)
c.APIKey = savedKey
if err != nil {
return "", "", err
}
return resp.DeviceID, resp.APIKey, nil
}
// Login authenticates with user credentials and returns a session token.
func (c *Client) Login(username, password string) (token string, err error) {
body := map[string]string{"username": username, "password": password}
var resp struct {
Token string `json:"token"`
}
savedKey := c.APIKey
c.APIKey = ""
err = c.post("/api/v1/auth/login", body, &resp)
c.APIKey = savedKey
if err != nil {
return "", err
}
return resp.Token, nil
}
// TestAuth checks credentials without creating a device.
func (c *Client) TestAuth(serverURL, username, password string) error {
body := map[string]string{"username": username, "password": password}
savedURL := c.ServerURL
savedKey := c.APIKey
c.ServerURL = serverURL
c.APIKey = ""
err := c.post("/api/auth/test", body, nil)
c.ServerURL = savedURL
c.APIKey = savedKey
return err
}
// PushRequest is the payload for POST /sync/push.
type PushRequest struct {
DeviceID string `json:"device_id"`
IdempotencyKey string `json:"idempotency_key,omitempty"`
Ops []PushOp `json:"ops"`
}
// PushOp is a single operation in a push request.
type PushOp struct {
OpID string `json:"op_id"`
EntityType string `json:"entity_type"`
EntityID string `json:"entity_id"`
OpType string `json:"op_type"`
PayloadJSON string `json:"payload_json"`
ClientSequence int `json:"client_sequence"`
LastSeenServerSeq int `json:"last_seen_server_seq"`
CreatedAt string `json:"created_at"`
}
// PushResponse is the response from POST /sync/push.
type PushResponse struct {
Accepted []string `json:"accepted"`
Count int `json:"count"`
Conflicts []map[string]interface{} `json:"conflicts"`
}
// Push sends local operations to the server.
func (c *Client) Push(ops []Op) (*PushResponse, error) {
pushOps := make([]PushOp, len(ops))
for i, op := range ops {
pushOps[i] = PushOp{
OpID: op.OpID,
EntityType: op.EntityType,
EntityID: op.EntityID,
OpType: op.OpType,
PayloadJSON: op.PayloadJSON,
ClientSequence: op.ClientSequence,
LastSeenServerSeq: op.LastSeenServerSeq,
CreatedAt: op.CreatedAt,
}
}
req := PushRequest{DeviceID: c.DeviceID, Ops: pushOps}
var resp PushResponse
if err := c.post("/api/v1/sync/push", req, &resp); err != nil {
return nil, err
}
return &resp, nil
}
// PullRequest is the payload for POST /sync/pull.
type PullRequest struct {
SinceSequence int `json:"since_sequence"`
}
// PullResponse is the response from POST /sync/pull.
type PullResponse struct {
ServerSequence int `json:"server_sequence"`
Ops []Op `json:"ops"`
}
// Pull fetches remote operations since a given sequence.
func (c *Client) Pull(sinceSequence int) (*PullResponse, error) {
req := PullRequest{SinceSequence: sinceSequence}
var resp PullResponse
if err := c.post("/api/v1/sync/pull", req, &resp); err != nil {
return nil, err
}
return &resp, nil
}
// UploadBlob uploads a file to the server and returns its SHA-256.
func (c *Client) UploadBlob(localPath string) (sha256 string, err error) {
var b bytes.Buffer
w := multipart.NewWriter(&b)
fw, err := w.CreateFormFile("file", filepath.Base(localPath))
if err != nil {
return "", err
}
f, err := os.Open(localPath)
if err != nil {
return "", err
}
defer f.Close()
if _, err := io.Copy(fw, f); err != nil {
return "", err
}
w.Close()
req, err := http.NewRequest("POST", c.ServerURL+"/api/v1/blobs/", &b)
if err != nil {
return "", err
}
req.Header.Set("Content-Type", w.FormDataContentType())
req.Header.Set("Authorization", "Bearer "+c.bearerToken())
resp, err := c.HTTP.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
var result struct {
SHA256 string `json:"sha256"`
Size int `json:"size"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", err
}
return result.SHA256, nil
}
// DownloadBlob downloads a blob by SHA-256 hash.
func (c *Client) DownloadBlob(sha256, destPath string) error {
req, err := http.NewRequest("GET", c.ServerURL+"/api/v1/blobs/"+sha256, nil)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+c.bearerToken())
resp, err := c.HTTP.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return fmt.Errorf("download blob: HTTP %d", resp.StatusCode)
}
out, err := os.Create(destPath)
if err != nil {
return err
}
defer out.Close()
_, err = io.Copy(out, resp.Body)
return err
}
// --- internal ---
func (c *Client) bearerToken() string {
if c.DeviceToken != "" {
return c.DeviceToken
}
return c.APIKey
}
func (c *Client) post(path string, body, result interface{}) error {
var b bytes.Buffer
if body != nil {
if err := json.NewEncoder(&b).Encode(body); err != nil {
return err
}
}
req, err := http.NewRequest("POST", c.ServerURL+path, &b)
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+c.bearerToken())
resp, err := c.HTTP.Do(req)
if err != nil {
return fmt.Errorf("http: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
data, _ := io.ReadAll(resp.Body)
return fmt.Errorf("server %d: %s", resp.StatusCode, string(data))
}
if result != nil {
return json.NewDecoder(resp.Body).Decode(result)
}
return nil
}
func (c *Client) get(path string, result interface{}) error {
req, err := http.NewRequest("GET", c.ServerURL+path, nil)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+c.bearerToken())
resp, err := c.HTTP.Do(req)
if err != nil {
return fmt.Errorf("http: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
data, _ := io.ReadAll(resp.Body)
return fmt.Errorf("server %d: %s", resp.StatusCode, string(data))
}
if result != nil {
return json.NewDecoder(resp.Body).Decode(result)
}
return nil
}