* Replaced initial node.js prototype of CLI with proper Go implementation * Added code signing for CLI + server binaries for macOS
298 lines
7.4 KiB
Go
298 lines
7.4 KiB
Go
package cli
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/silverbulletmd/silverbullet/server"
|
|
)
|
|
|
|
type apiResponse struct {
|
|
Result any `json:"result"`
|
|
Error string `json:"error"`
|
|
}
|
|
|
|
type SpaceConnection struct {
|
|
baseURL string
|
|
token string
|
|
spaceConfig *SpaceConfig
|
|
cachedJWT string
|
|
cookieName string
|
|
Timeout time.Duration
|
|
}
|
|
|
|
type ConnectionOpts struct {
|
|
Space *SpaceConfig
|
|
URL string
|
|
Token string
|
|
Timeout time.Duration
|
|
}
|
|
|
|
func NewSpaceConnection(opts ConnectionOpts) (*SpaceConnection, error) {
|
|
conn := &SpaceConnection{
|
|
Timeout: opts.Timeout,
|
|
token: opts.Token,
|
|
}
|
|
if conn.Timeout == 0 {
|
|
conn.Timeout = 30 * time.Second
|
|
}
|
|
if opts.URL != "" {
|
|
conn.baseURL = strings.TrimRight(opts.URL, "/")
|
|
} else if opts.Space != nil {
|
|
conn.baseURL = strings.TrimRight(opts.Space.URL, "/")
|
|
conn.spaceConfig = opts.Space
|
|
} else {
|
|
return nil, fmt.Errorf("no URL or space configured")
|
|
}
|
|
return conn, nil
|
|
}
|
|
|
|
func (c *SpaceConnection) client() *http.Client {
|
|
return &http.Client{
|
|
Timeout: c.Timeout,
|
|
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
|
return http.ErrUseLastResponse
|
|
},
|
|
}
|
|
}
|
|
|
|
func (c *SpaceConnection) buildHeaders() (http.Header, error) {
|
|
h := http.Header{}
|
|
if c.token != "" {
|
|
h.Set("Authorization", "Bearer "+c.token)
|
|
return h, nil
|
|
}
|
|
if c.spaceConfig == nil {
|
|
return h, nil
|
|
}
|
|
s := c.spaceConfig
|
|
switch s.Auth.Method {
|
|
case "token":
|
|
if s.Auth.EncryptedToken != "" {
|
|
tok, err := Decrypt(s.Auth.EncryptedToken)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("decrypting token: %w", err)
|
|
}
|
|
h.Set("Authorization", "Bearer "+tok)
|
|
}
|
|
case "password":
|
|
jwt, cookieName, err := c.loginForJWT()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
h.Set("Cookie", cookieName+"="+jwt)
|
|
}
|
|
return h, nil
|
|
}
|
|
|
|
func (c *SpaceConnection) loginForJWT() (string, string, error) {
|
|
if c.cachedJWT != "" {
|
|
return c.cachedJWT, c.cookieName, nil
|
|
}
|
|
s := c.spaceConfig
|
|
password, err := Decrypt(s.Auth.EncryptedPassword)
|
|
if err != nil {
|
|
return "", "", fmt.Errorf("decrypting password: %w", err)
|
|
}
|
|
form := url.Values{}
|
|
form.Set("username", s.Auth.Username)
|
|
form.Set("password", password)
|
|
req, err := http.NewRequest("POST", c.baseURL+"/.auth", strings.NewReader(form.Encode()))
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
resp, err := c.client().Do(req)
|
|
if err != nil {
|
|
return "", "", fmt.Errorf("login request failed: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
setCookie := resp.Header.Get("Set-Cookie")
|
|
if setCookie == "" {
|
|
return "", "", fmt.Errorf("login failed (status %d): no auth cookie returned", resp.StatusCode)
|
|
}
|
|
// Parse "auth_xxx=<jwt>; ..." format
|
|
for _, part := range strings.Split(setCookie, ";") {
|
|
part = strings.TrimSpace(part)
|
|
if strings.HasPrefix(part, "auth_") {
|
|
eqIdx := strings.Index(part, "=")
|
|
if eqIdx > 0 {
|
|
c.cookieName = part[:eqIdx]
|
|
c.cachedJWT = part[eqIdx+1:]
|
|
return c.cachedJWT, c.cookieName, nil
|
|
}
|
|
}
|
|
}
|
|
return "", "", fmt.Errorf("login failed: could not extract auth token from cookie")
|
|
}
|
|
|
|
func (c *SpaceConnection) apiPost(endpoint string, body string) (any, error) {
|
|
headers, err := c.buildHeaders()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req, err := http.NewRequest("POST", c.baseURL+endpoint, strings.NewReader(body))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header = headers
|
|
req.Header.Set("Content-Type", "text/plain")
|
|
|
|
resp, err := c.client().Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode == 401 || (resp.StatusCode >= 300 && resp.StatusCode < 400) {
|
|
return nil, fmt.Errorf("authentication required; use --token, or configure a space with 'space add'")
|
|
}
|
|
|
|
respBody, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var data apiResponse
|
|
if err := json.Unmarshal(respBody, &data); err != nil {
|
|
if resp.StatusCode >= 400 {
|
|
return nil, fmt.Errorf("server returned %d: %s", resp.StatusCode, string(respBody))
|
|
}
|
|
return nil, fmt.Errorf("invalid JSON response: %w", err)
|
|
}
|
|
if data.Error != "" {
|
|
return nil, fmt.Errorf("%s", data.Error)
|
|
}
|
|
return data.Result, nil
|
|
}
|
|
|
|
func (c *SpaceConnection) apiGet(endpoint string, params map[string]string) (*http.Response, error) {
|
|
headers, err := c.buildHeaders()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
u, err := url.Parse(c.baseURL + endpoint)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if params != nil {
|
|
q := u.Query()
|
|
for k, v := range params {
|
|
q.Set(k, v)
|
|
}
|
|
u.RawQuery = q.Encode()
|
|
}
|
|
req, err := http.NewRequest("GET", u.String(), nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header = headers
|
|
|
|
resp, err := c.client().Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if resp.StatusCode == 401 || (resp.StatusCode >= 300 && resp.StatusCode < 400) {
|
|
resp.Body.Close()
|
|
return nil, fmt.Errorf("authentication required; use --token, or configure a space with 'space add'")
|
|
}
|
|
if resp.StatusCode >= 400 {
|
|
body, _ := io.ReadAll(resp.Body)
|
|
resp.Body.Close()
|
|
return nil, fmt.Errorf("server returned %d: %s", resp.StatusCode, string(body))
|
|
}
|
|
return resp, nil
|
|
}
|
|
|
|
func (c *SpaceConnection) EvalLua(expression string) (any, error) {
|
|
return c.apiPost("/.runtime/lua", expression)
|
|
}
|
|
|
|
func (c *SpaceConnection) EvalLuaScript(script string) (any, error) {
|
|
return c.apiPost("/.runtime/lua_script", script)
|
|
}
|
|
|
|
func (c *SpaceConnection) Logs(limit int, since int64) ([]server.ConsoleLogEntry, error) {
|
|
params := map[string]string{}
|
|
if limit > 0 {
|
|
params["limit"] = fmt.Sprintf("%d", limit)
|
|
}
|
|
if since > 0 {
|
|
params["since"] = fmt.Sprintf("%d", since)
|
|
}
|
|
resp, err := c.apiGet("/.runtime/logs", params)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
var data struct {
|
|
Logs []server.ConsoleLogEntry `json:"logs"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
|
|
return nil, err
|
|
}
|
|
return data.Logs, nil
|
|
}
|
|
|
|
func (c *SpaceConnection) Screenshot() ([]byte, error) {
|
|
resp, err := c.apiGet("/.runtime/screenshot", nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
return io.ReadAll(resp.Body)
|
|
}
|
|
|
|
// AuthCheck verifies that the connection's credentials are accepted by the server.
|
|
// Unlike Ping, this hits an authenticated endpoint (/.config).
|
|
func (c *SpaceConnection) AuthCheck() bool {
|
|
resp, err := c.apiGet("/.config", nil)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
resp.Body.Close()
|
|
return true
|
|
}
|
|
|
|
func (c *SpaceConnection) Ping() bool {
|
|
client := &http.Client{Timeout: 5 * time.Second}
|
|
resp, err := client.Get(c.baseURL + "/.ping")
|
|
if err != nil {
|
|
return false
|
|
}
|
|
resp.Body.Close()
|
|
return resp.StatusCode >= 200 && resp.StatusCode < 300
|
|
}
|
|
|
|
// Probe checks whether the server is reachable and whether it requires authentication.
|
|
// Returns (reachable, needsAuth). It uses /.config (which is behind auth) to detect
|
|
// whether authentication is required, and falls back to /.ping for reachability.
|
|
func (c *SpaceConnection) Probe() (bool, bool) {
|
|
client := &http.Client{
|
|
Timeout: 5 * time.Second,
|
|
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
|
return http.ErrUseLastResponse
|
|
},
|
|
}
|
|
// /.config is behind the auth middleware (unlike /.ping which is excluded)
|
|
resp, err := client.Get(c.baseURL + "/.config")
|
|
if err != nil {
|
|
return false, false
|
|
}
|
|
resp.Body.Close()
|
|
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
|
return true, false
|
|
}
|
|
if resp.StatusCode == 401 || (resp.StatusCode >= 300 && resp.StatusCode < 400) {
|
|
return true, true
|
|
}
|
|
return false, false
|
|
}
|