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}