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}