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}