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