client.go

  1// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
  2//
  3// SPDX-License-Identifier: LicenseRef-MutuaL-1.2
  4
  5// Package silverbullet provides an HTTP client for the SilverBullet Runtime API.
  6package silverbullet
  7
  8import (
  9	"bytes"
 10	"context"
 11	"encoding/json"
 12	"fmt"
 13	"io"
 14	"net/http"
 15	"net/http/cookiejar"
 16	"net/url"
 17	"strconv"
 18	"strings"
 19	"sync"
 20	"time"
 21)
 22
 23// Auth holds authentication credentials for the SilverBullet API.
 24type Auth struct {
 25	// Bearer token for Authorization header. Set via SB_TOKEN.
 26	Token string
 27	// Username and password for cookie-based session auth.
 28	// Logs in via POST /.auth and sends session cookie on subsequent requests.
 29	User string
 30	Pass string
 31}
 32
 33// Client is an HTTP client for the SilverBullet Runtime API.
 34type Client struct {
 35	baseURL    string
 36	httpClient *http.Client
 37	auth       Auth
 38	authMu     sync.Mutex
 39}
 40
 41// New creates a new SilverBullet client.
 42// The baseURL is normalized to remove trailing slashes.
 43func New(baseURL string, auth Auth) *Client {
 44	jar, _ := cookiejar.New(nil)
 45	return &Client{
 46		baseURL: strings.TrimRight(baseURL, "/"),
 47		httpClient: &http.Client{
 48			Timeout: 6 * time.Hour, // long enough for lua_script with X-Timeout up to 21600s
 49			Jar:     jar,
 50			CheckRedirect: func(req *http.Request, via []*http.Request) error {
 51				return http.ErrUseLastResponse
 52			},
 53		},
 54		auth: auth,
 55	}
 56}
 57
 58// LuaResult holds the result from the Lua endpoints.
 59type LuaResult struct {
 60	Result json.RawMessage `json:"result,omitempty"`
 61	Error  string          `json:"error,omitempty"`
 62}
 63
 64// LogEntry represents a single console log entry.
 65type LogEntry struct {
 66	Level     string `json:"level"`
 67	Text      string `json:"text"`
 68	Timestamp int64  `json:"timestamp"`
 69}
 70
 71// LogsResult holds the result from the logs endpoint.
 72type LogsResult struct {
 73	Logs []LogEntry `json:"logs"`
 74}
 75
 76// ExecuteLua sends a Lua script to the /.runtime/lua_script endpoint.
 77func (c *Client) ExecuteLua(ctx context.Context, script string, timeout int) (*LuaResult, error) {
 78	if timeout < 1 {
 79		timeout = 1
 80	}
 81	if timeout > 21600 {
 82		timeout = 21600
 83	}
 84
 85	endpoint, err := c.resolveURL("/.runtime/lua_script")
 86	if err != nil {
 87		return nil, fmt.Errorf("resolving URL: %w", err)
 88	}
 89
 90	req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewBufferString(script))
 91	if err != nil {
 92		return nil, fmt.Errorf("creating request: %w", err)
 93	}
 94
 95	req.Header.Set("Content-Type", "text/plain")
 96	req.Header.Set("X-Timeout", strconv.Itoa(timeout))
 97
 98	resp, err := c.do(req)
 99	if err != nil {
100		return nil, fmt.Errorf("executing lua: %w", err)
101	}
102	defer func() { _ = resp.Body.Close() }()
103
104	body, err := io.ReadAll(resp.Body)
105	if err != nil {
106		return nil, fmt.Errorf("reading response: %w", err)
107	}
108
109	if resp.StatusCode != http.StatusOK {
110		var luaErr LuaResult
111		if json.Unmarshal(body, &luaErr) == nil && luaErr.Error != "" {
112			return &luaErr, nil
113		}
114		return nil, fmt.Errorf("lua endpoint returned status %d: %s", resp.StatusCode, string(body))
115	}
116
117	var result LuaResult
118	if err := json.Unmarshal(body, &result); err != nil {
119		return nil, fmt.Errorf("parsing lua response: %w", err)
120	}
121
122	return &result, nil
123}
124
125// Screenshot fetches the current viewport screenshot as PNG bytes.
126func (c *Client) Screenshot(ctx context.Context) ([]byte, error) {
127	endpoint, err := c.resolveURL("/.runtime/screenshot")
128	if err != nil {
129		return nil, fmt.Errorf("resolving URL: %w", err)
130	}
131
132	req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
133	if err != nil {
134		return nil, fmt.Errorf("creating request: %w", err)
135	}
136
137	resp, err := c.do(req)
138	if err != nil {
139		return nil, fmt.Errorf("fetching screenshot: %w", err)
140	}
141	defer func() { _ = resp.Body.Close() }()
142
143	if resp.StatusCode != http.StatusOK {
144		body, _ := io.ReadAll(resp.Body)
145		return nil, fmt.Errorf("screenshot endpoint returned status %d: %s", resp.StatusCode, string(body))
146	}
147
148	data, err := io.ReadAll(resp.Body)
149	if err != nil {
150		return nil, fmt.Errorf("reading screenshot data: %w", err)
151	}
152
153	return data, nil
154}
155
156// ConsoleLogs fetches recent console log entries.
157// limit caps the number of entries (max 1000).
158// since filters to entries newer than the given unix millisecond timestamp (0 = no filter).
159func (c *Client) ConsoleLogs(ctx context.Context, limit int, since int64) (*LogsResult, error) {
160	if limit < 1 {
161		limit = 100
162	}
163	if limit > 1000 {
164		limit = 1000
165	}
166
167	endpoint, err := c.resolveURL("/.runtime/logs")
168	if err != nil {
169		return nil, fmt.Errorf("resolving URL: %w", err)
170	}
171
172	req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
173	if err != nil {
174		return nil, fmt.Errorf("creating request: %w", err)
175	}
176
177	q := req.URL.Query()
178	q.Set("limit", strconv.Itoa(limit))
179	if since > 0 {
180		q.Set("since", strconv.FormatInt(since, 10))
181	}
182	req.URL.RawQuery = q.Encode()
183
184	resp, err := c.do(req)
185	if err != nil {
186		return nil, fmt.Errorf("fetching logs: %w", err)
187	}
188	defer func() { _ = resp.Body.Close() }()
189
190	body, err := io.ReadAll(resp.Body)
191	if err != nil {
192		return nil, fmt.Errorf("reading logs response: %w", err)
193	}
194
195	if resp.StatusCode != http.StatusOK {
196		return nil, fmt.Errorf("logs endpoint returned status %d: %s", resp.StatusCode, string(body))
197	}
198
199	var result LogsResult
200	if err := json.Unmarshal(body, &result); err != nil {
201		return nil, fmt.Errorf("parsing logs response: %w", err)
202	}
203
204	return &result, nil
205}
206
207// resolveURL joins the base URL with a path, ensuring no double slashes.
208func (c *Client) resolveURL(path string) (string, error) {
209	return url.JoinPath(c.baseURL, path)
210}
211
212// do sends an authenticated request.
213// If a password session is rejected, it discards the cached cookie, logs in again,
214// and retries the request once.
215func (c *Client) do(req *http.Request) (*http.Response, error) {
216	if err := c.setAuth(req); err != nil {
217		return nil, fmt.Errorf("setting auth: %w", err)
218	}
219
220	resp, err := c.httpClient.Do(req)
221	if err != nil {
222		return nil, err
223	}
224	if !c.usesPasswordAuth() || !isAuthFailure(resp.StatusCode) {
225		return resp, nil
226	}
227
228	_, _ = io.Copy(io.Discard, resp.Body)
229	_ = resp.Body.Close()
230
231	if req.Body != nil && req.GetBody == nil {
232		return nil, fmt.Errorf("authentication failed and request body cannot be replayed")
233	}
234	if err := c.expireSessionCookies(req.URL); err != nil {
235		return nil, fmt.Errorf("expiring session cookie: %w", err)
236	}
237
238	retryReq := req.Clone(req.Context())
239	retryReq.Header = req.Header.Clone()
240	retryReq.Header.Del("Cookie")
241	if req.GetBody != nil {
242		retryBody, err := req.GetBody()
243		if err != nil {
244			return nil, fmt.Errorf("recreating request body: %w", err)
245		}
246		retryReq.Body = retryBody
247	}
248
249	if err := c.setAuth(retryReq); err != nil {
250		return nil, fmt.Errorf("refreshing auth: %w", err)
251	}
252
253	return c.httpClient.Do(retryReq)
254}
255
256func (c *Client) usesPasswordAuth() bool {
257	return c.auth.User != "" && c.auth.Pass != ""
258}
259
260func isAuthFailure(status int) bool {
261	return status == http.StatusUnauthorized || (status >= 300 && status < 400)
262}
263
264// setAuth sets authentication headers on the request.
265// Bearer token goes on the Authorization header.
266// Password auth logs in via POST /.auth; the client's cookie jar sends the
267// resulting session cookie on subsequent requests until it expires.
268// Both can coexist — they use different headers.
269func (c *Client) setAuth(req *http.Request) error {
270	// Bearer token on Authorization header
271	if c.auth.Token != "" {
272		req.Header.Set("Authorization", "Bearer "+c.auth.Token)
273	}
274
275	// Password auth via session cookie
276	if c.usesPasswordAuth() {
277		if err := c.ensureSessionCookie(req); err != nil {
278			return fmt.Errorf("session login: %w", err)
279		}
280	}
281
282	return nil
283}
284
285// ensureSessionCookie logs in via POST /.auth if the cookie jar has no current
286// auth cookie. The jar applies normal cookie expiration rules.
287func (c *Client) ensureSessionCookie(req *http.Request) error {
288	loginURL, err := c.resolveURL("/.auth")
289	if err != nil {
290		return fmt.Errorf("resolving login URL: %w", err)
291	}
292	loginCookieURL, err := url.Parse(loginURL)
293	if err != nil {
294		return fmt.Errorf("parsing login URL: %w", err)
295	}
296
297	c.authMu.Lock()
298	defer c.authMu.Unlock()
299
300	if c.hasAuthCookie(loginCookieURL) {
301		return nil
302	}
303
304	form := url.Values{}
305	form.Set("username", c.auth.User)
306	form.Set("password", c.auth.Pass)
307
308	loginReq, err := http.NewRequestWithContext(req.Context(), http.MethodPost, loginURL, strings.NewReader(form.Encode()))
309	if err != nil {
310		return fmt.Errorf("creating login request: %w", err)
311	}
312	loginReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
313
314	loginResp, err := c.httpClient.Do(loginReq)
315	if err != nil {
316		return fmt.Errorf("login request failed: %w", err)
317	}
318	defer func() { _ = loginResp.Body.Close() }()
319
320	if loginResp.StatusCode != http.StatusOK {
321		return fmt.Errorf("login failed with status %d", loginResp.StatusCode)
322	}
323	if !c.hasAuthCookie(loginCookieURL) {
324		return fmt.Errorf("login succeeded but no auth cookie returned")
325	}
326
327	return nil
328}
329
330func (c *Client) hasAuthCookie(u *url.URL) bool {
331	if c.httpClient.Jar == nil {
332		return false
333	}
334	for _, cookie := range c.httpClient.Jar.Cookies(u) {
335		if strings.HasPrefix(cookie.Name, "auth_") && cookie.Value != "" {
336			return true
337		}
338	}
339	return false
340}
341
342func (c *Client) expireSessionCookies(u *url.URL) error {
343	if c.httpClient.Jar == nil {
344		return nil
345	}
346
347	c.authMu.Lock()
348	defer c.authMu.Unlock()
349
350	cookies := c.httpClient.Jar.Cookies(u)
351	for _, cookie := range cookies {
352		if strings.HasPrefix(cookie.Name, "auth_") {
353			c.httpClient.Jar.SetCookies(u, []*http.Cookie{{
354				Name:   cookie.Name,
355				Value:  "",
356				Path:   "/",
357				MaxAge: -1,
358			}})
359		}
360	}
361	return nil
362}