client.go

  1// Package silverbullet provides an HTTP client for the SilverBullet Runtime API.
  2package silverbullet
  3
  4import (
  5	"bytes"
  6	"context"
  7	"encoding/json"
  8	"fmt"
  9	"io"
 10	"net/http"
 11	"net/url"
 12	"strconv"
 13	"strings"
 14	"time"
 15)
 16
 17// Auth holds authentication credentials for the SilverBullet API.
 18type Auth struct {
 19	// Bearer token for Authorization header. Set via SB_TOKEN.
 20	Token string
 21	// Username and password for cookie-based session auth.
 22	// Logs in via POST /.auth and sends session cookie on subsequent requests.
 23	User string
 24	Pass string
 25}
 26
 27// Client is an HTTP client for the SilverBullet Runtime API.
 28type Client struct {
 29	baseURL    string
 30	httpClient *http.Client
 31	auth       Auth
 32	// cached session from password login
 33	cachedCookieName string
 34	cachedCookieVal  string
 35}
 36
 37// New creates a new SilverBullet client.
 38// The baseURL is normalized to remove trailing slashes.
 39func New(baseURL string, auth Auth) *Client {
 40	return &Client{
 41		baseURL: strings.TrimRight(baseURL, "/"),
 42		httpClient: &http.Client{
 43			Timeout: 6 * time.Hour, // long enough for lua_script with X-Timeout up to 21600s
 44			CheckRedirect: func(req *http.Request, via []*http.Request) error {
 45				return http.ErrUseLastResponse
 46			},
 47		},
 48		auth: auth,
 49	}
 50}
 51
 52// LuaResult holds the result from the Lua endpoints.
 53type LuaResult struct {
 54	Result json.RawMessage `json:"result,omitempty"`
 55	Error  string          `json:"error,omitempty"`
 56}
 57
 58// LogEntry represents a single console log entry.
 59type LogEntry struct {
 60	Level     string `json:"level"`
 61	Text      string `json:"text"`
 62	Timestamp int64  `json:"timestamp"`
 63}
 64
 65// LogsResult holds the result from the logs endpoint.
 66type LogsResult struct {
 67	Logs []LogEntry `json:"logs"`
 68}
 69
 70// ExecuteLua sends a Lua script to the /.runtime/lua_script endpoint.
 71func (c *Client) ExecuteLua(ctx context.Context, script string, timeout int) (*LuaResult, error) {
 72	if timeout < 1 {
 73		timeout = 1
 74	}
 75	if timeout > 21600 {
 76		timeout = 21600
 77	}
 78
 79	endpoint, err := c.resolveURL("/.runtime/lua_script")
 80	if err != nil {
 81		return nil, fmt.Errorf("resolving URL: %w", err)
 82	}
 83
 84	req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewBufferString(script))
 85	if err != nil {
 86		return nil, fmt.Errorf("creating request: %w", err)
 87	}
 88
 89	req.Header.Set("Content-Type", "text/plain")
 90	req.Header.Set("X-Timeout", strconv.Itoa(timeout))
 91	if err := c.setAuth(req); err != nil {
 92		return nil, fmt.Errorf("setting auth: %w", err)
 93	}
 94
 95	resp, err := c.httpClient.Do(req)
 96	if err != nil {
 97		return nil, fmt.Errorf("executing lua: %w", err)
 98	}
 99	defer resp.Body.Close()
100
101	body, err := io.ReadAll(resp.Body)
102	if err != nil {
103		return nil, fmt.Errorf("reading response: %w", err)
104	}
105
106	if resp.StatusCode != http.StatusOK {
107		var luaErr LuaResult
108		if json.Unmarshal(body, &luaErr) == nil && luaErr.Error != "" {
109			return &luaErr, nil
110		}
111		return nil, fmt.Errorf("lua endpoint returned status %d: %s", resp.StatusCode, string(body))
112	}
113
114	var result LuaResult
115	if err := json.Unmarshal(body, &result); err != nil {
116		return nil, fmt.Errorf("parsing lua response: %w", err)
117	}
118
119	return &result, nil
120}
121
122// Screenshot fetches the current viewport screenshot as PNG bytes.
123func (c *Client) Screenshot(ctx context.Context) ([]byte, error) {
124	endpoint, err := c.resolveURL("/.runtime/screenshot")
125	if err != nil {
126		return nil, fmt.Errorf("resolving URL: %w", err)
127	}
128
129	req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
130	if err != nil {
131		return nil, fmt.Errorf("creating request: %w", err)
132	}
133
134	if err := c.setAuth(req); err != nil {
135		return nil, fmt.Errorf("setting auth: %w", err)
136	}
137
138	resp, err := c.httpClient.Do(req)
139	if err != nil {
140		return nil, fmt.Errorf("fetching screenshot: %w", err)
141	}
142	defer resp.Body.Close()
143
144	if resp.StatusCode != http.StatusOK {
145		body, _ := io.ReadAll(resp.Body)
146		return nil, fmt.Errorf("screenshot endpoint returned status %d: %s", resp.StatusCode, string(body))
147	}
148
149	data, err := io.ReadAll(resp.Body)
150	if err != nil {
151		return nil, fmt.Errorf("reading screenshot data: %w", err)
152	}
153
154	return data, nil
155}
156
157// ConsoleLogs fetches recent console log entries.
158// limit caps the number of entries (max 1000).
159// since filters to entries newer than the given unix millisecond timestamp (0 = no filter).
160func (c *Client) ConsoleLogs(ctx context.Context, limit int, since int64) (*LogsResult, error) {
161	if limit < 1 {
162		limit = 100
163	}
164	if limit > 1000 {
165		limit = 1000
166	}
167
168	endpoint, err := c.resolveURL("/.runtime/logs")
169	if err != nil {
170		return nil, fmt.Errorf("resolving URL: %w", err)
171	}
172
173	req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
174	if err != nil {
175		return nil, fmt.Errorf("creating request: %w", err)
176	}
177
178	if err := c.setAuth(req); err != nil {
179		return nil, fmt.Errorf("setting auth: %w", err)
180	}
181
182	q := req.URL.Query()
183	q.Set("limit", strconv.Itoa(limit))
184	if since > 0 {
185		q.Set("since", strconv.FormatInt(since, 10))
186	}
187	req.URL.RawQuery = q.Encode()
188
189	resp, err := c.httpClient.Do(req)
190	if err != nil {
191		return nil, fmt.Errorf("fetching logs: %w", err)
192	}
193	defer resp.Body.Close()
194
195	body, err := io.ReadAll(resp.Body)
196	if err != nil {
197		return nil, fmt.Errorf("reading logs response: %w", err)
198	}
199
200	if resp.StatusCode != http.StatusOK {
201		return nil, fmt.Errorf("logs endpoint returned status %d: %s", resp.StatusCode, string(body))
202	}
203
204	var result LogsResult
205	if err := json.Unmarshal(body, &result); err != nil {
206		return nil, fmt.Errorf("parsing logs response: %w", err)
207	}
208
209	return &result, nil
210}
211
212// resolveURL joins the base URL with a path, ensuring no double slashes.
213func (c *Client) resolveURL(path string) (string, error) {
214	return url.JoinPath(c.baseURL, path)
215}
216
217// setAuth sets authentication headers on the request.
218// Bearer token goes on the Authorization header.
219// Password auth logs in via POST /.auth and sends the session cookie.
220// Both can coexist — they use different headers.
221func (c *Client) setAuth(req *http.Request) error {
222	// Bearer token on Authorization header
223	if c.auth.Token != "" {
224		req.Header.Set("Authorization", "Bearer "+c.auth.Token)
225	}
226
227	// Password auth via session cookie
228	if c.auth.User != "" && c.auth.Pass != "" {
229		if err := c.ensureSessionCookie(req); err != nil {
230			return fmt.Errorf("session login: %w", err)
231		}
232	}
233
234	return nil
235}
236
237// ensureSessionCookie logs in via POST /.auth if needed and sets the session cookie.
238func (c *Client) ensureSessionCookie(req *http.Request) error {
239	// Use cached session if available
240	if c.cachedCookieName != "" && c.cachedCookieVal != "" {
241		req.AddCookie(&http.Cookie{
242			Name:  c.cachedCookieName,
243			Value: c.cachedCookieVal,
244		})
245		return nil
246	}
247
248	// Log in to get session cookie
249	form := url.Values{}
250	form.Set("username", c.auth.User)
251	form.Set("password", c.auth.Pass)
252
253	loginURL, err := c.resolveURL("/.auth")
254	if err != nil {
255		return fmt.Errorf("resolving login URL: %w", err)
256	}
257
258	loginReq, err := http.NewRequestWithContext(req.Context(), http.MethodPost, loginURL, strings.NewReader(form.Encode()))
259	if err != nil {
260		return fmt.Errorf("creating login request: %w", err)
261	}
262	loginReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
263
264	loginResp, err := c.httpClient.Do(loginReq)
265	if err != nil {
266		return fmt.Errorf("login request failed: %w", err)
267	}
268	defer loginResp.Body.Close()
269
270	if loginResp.StatusCode != http.StatusOK {
271		return fmt.Errorf("login failed with status %d", loginResp.StatusCode)
272	}
273
274	// Extract session cookie from Set-Cookie header
275	for _, cookie := range loginResp.Cookies() {
276		if strings.HasPrefix(cookie.Name, "auth_") {
277			c.cachedCookieName = cookie.Name
278			c.cachedCookieVal = cookie.Value
279			req.AddCookie(&http.Cookie{
280				Name:  cookie.Name,
281				Value: cookie.Value,
282			})
283			return nil
284		}
285	}
286
287	return fmt.Errorf("login succeeded but no auth cookie returned")
288}