aboutsummaryrefslogtreecommitdiffstats
path: root/server
diff options
context:
space:
mode:
authorElvis Claros Castro <elvis@claros.ar>2026-10-03 23:29:44 -0300
committerElvis Claros Castro <elvis@claros.ar>2026-10-03 23:29:44 -0300
commit80ea7b675e918b930af01b82b60583d474bc0105 (patch)
tree0d73b3a65344e810be61263dfa86490eef68510b /server
parentf546a8a1578bef9fbc144580b84f51e3e90480b1 (diff)
downloadclaude-logbook-80ea7b675e918b930af01b82b60583d474bc0105.tar.gz
claude-logbook-80ea7b675e918b930af01b82b60583d474bc0105.zip
Share and export a session's images with --imagesHEADmain
Diffstat (limited to 'server')
-rw-r--r--server/README.md6
-rw-r--r--server/home.html2
-rw-r--r--server/main.go4
-rw-r--r--server/render.go72
-rw-r--r--server/server_test.go30
5 files changed, 105 insertions, 9 deletions
diff --git a/server/README.md b/server/README.md
index 8e2de37..a74cb5c 100644
--- a/server/README.md
+++ b/server/README.md
@@ -26,6 +26,8 @@ creation can replace or delete it. The server keeps only the secret's SHA-256.
two inline scripts by hash: no inline handlers, no external anything.
- Share ids are 10 base62 characters from `crypto/rand`; pages carry
`X-Robots-Tag: noindex` and `Referrer-Policy: no-referrer`.
+- Images (`--images`) are accepted only as base64 `data:` URLs of PNG, JPEG,
+ GIF or WebP whose bytes match the format they claim; SVG never.
- Limits: request body (`MAX_BYTES`), sessions per share (`MAX_SESSIONS`),
and a token bucket per client IP for creations and for updates/deletes.
- `X-Forwarded-For` is believed only when the connection comes from
@@ -41,7 +43,7 @@ Content is **not encrypted**: whoever runs the server can read the shares.
| `ADDR` | `-addr` | `:8181` |
| `DATA_DIR` | `-data` | `/data` |
| `PUBLIC_URL` | `-public-url` | `https://claude-logbook.all.ar` |
-| `MAX_BYTES` | `-max-bytes` | `10485760` (10 MB) |
+| `MAX_BYTES` | `-max-bytes` | `20971520` (20 MB) |
| `MAX_SESSIONS` | `-max-sessions` | `1` |
| `TRUSTED_PROXY` | `-trusted-proxy` | empty: trust no proxy (IPs or CIDRs, comma separated) |
| `CREATES_PER_HOUR` | `-creates-per-hour` | `10` per IP |
@@ -93,7 +95,7 @@ certificate (Let's Encrypt) and *Force SSL*. Add this under *Advanced* so
uploads up to `MAX_BYTES` get through:
```nginx
-client_max_body_size 10m;
+client_max_body_size 20m;
```
`TRUSTED_PROXY` must be the address the connections from NPM arrive from.
diff --git a/server/home.html b/server/home.html
index 6ae6ad1..ffc559e 100644
--- a/server/home.html
+++ b/server/home.html
@@ -59,6 +59,7 @@ claude-logbook <span class="c"># table of every session</span>
<p>Pick a session by its number in the table or by a prefix of its UUID, then share it:</p>
<pre><code>claude-logbook -s 3 --share <span class="c"># asks before uploading</span>
claude-logbook -s 5d10f1ee --share --expire 7d
+claude-logbook -s 3 --share --images <span class="c"># with its images</span>
claude-logbook --shares <span class="c"># what you have shared</span>
claude-logbook --unshare 5d10f1ee <span class="c"># take it down</span></code></pre>
<p>Sharing the same session again updates it in place: the link stays the same.</p>
@@ -78,6 +79,7 @@ claude-logbook --unshare 5d10f1ee <span class="c"># take it down</span>
</div>
<ul>
<li><code>--share</code> warns if it spots things that look like API keys, tokens or private keys.</li>
+ <li>Images go only with <code>--images</code>, and nothing checks what they show: look at them first.</li>
<li>Shares expire after 30 days unless you pick <code>--expire 1d</code>, <code>7d</code> or <code>90d</code>.</li>
<li>Only the machine that created a share holds its secret and can update or delete it
(<code>~/.local/state/claude-logbook/shares.json</code>).</li>
diff --git a/server/main.go b/server/main.go
index 617a9b1..ab3c76d 100644
--- a/server/main.go
+++ b/server/main.go
@@ -392,7 +392,7 @@ func main() {
flag.StringVar(&cfg.Addr, "addr", env("ADDR", ":8181"), "listen address")
flag.StringVar(&cfg.DataDir, "data", env("DATA_DIR", "/data"), "where shares are stored")
flag.StringVar(&cfg.PublicURL, "public-url", env("PUBLIC_URL", "https://claude-logbook.all.ar"), "base of the URLs handed out")
- flag.Int64Var(&cfg.MaxBytes, "max-bytes", envInt("MAX_BYTES", 10<<20), "largest request body")
+ flag.Int64Var(&cfg.MaxBytes, "max-bytes", envInt("MAX_BYTES", 20<<20), "largest request body")
maxSessions := flag.Int64("max-sessions", envInt("MAX_SESSIONS", 1), "sessions per share")
flag.StringVar(&cfg.TrustedProxy, "trusted-proxy", env("TRUSTED_PROXY", ""), "IPs/CIDRs whose X-Forwarded-For is believed (comma separated)")
creates := flag.Int64("creates-per-hour", envInt("CREATES_PER_HOUR", 10), "new shares per IP and hour")
@@ -421,7 +421,7 @@ func main() {
Addr: cfg.Addr,
Handler: srv.Routes(),
ReadHeaderTimeout: 10 * time.Second,
- ReadTimeout: 60 * time.Second,
+ ReadTimeout: 180 * time.Second, // 20 MB of images on a slow uplink
WriteTimeout: 60 * time.Second,
IdleTimeout: 120 * time.Second,
MaxHeaderBytes: 16 << 10,
diff --git a/server/render.go b/server/render.go
index 2c72c1a..6acb8aa 100644
--- a/server/render.go
+++ b/server/render.go
@@ -22,8 +22,9 @@ const dataMarker = "__DATA__"
// ──────────────────────────────── validation ─────────────────────────────
type Block struct {
- R string `json:"r"`
- X string `json:"x"`
+ R string `json:"r"`
+ X string `json:"x"`
+ Src string `json:"src,omitempty"`
}
// Session mirrors a record of sessions.py without its internal keys.
@@ -84,7 +85,16 @@ func ParsePayload(raw []byte, maxSessions int) (*Payload, []byte, error) {
return nil, nil, fmt.Errorf("payload: session %d: no c", i)
}
for j, b := range s.C {
- if b.R != "u" && b.R != "a" && b.R != "t" && b.R != "c" && b.R != "d" {
+ switch b.R {
+ case "u", "a", "t", "c", "d":
+ if b.Src != "" {
+ return nil, nil, fmt.Errorf("payload: session %d block %d: src on a %q block", i, j, b.R)
+ }
+ case "i", "v":
+ if b.Src != "" && !validImage(b.Src) {
+ return nil, nil, fmt.Errorf("payload: session %d block %d: not a png, jpeg, gif or webp image", i, j)
+ }
+ default:
return nil, nil, fmt.Errorf("payload: session %d block %d: bad r", i, j)
}
}
@@ -100,6 +110,45 @@ func ParsePayload(raw []byte, maxSessions int) (*Payload, []byte, error) {
return &p, clean, err
}
+// imageMagic is how each accepted format starts. SVG is not among them: it
+// is a document that can carry a script.
+var imageMagic = map[string][]string{
+ "png": {"\x89PNG\r\n\x1a\n"},
+ "jpeg": {"\xff\xd8\xff"},
+ "gif": {"GIF87a", "GIF89a"},
+ "webp": {"RIFF"},
+}
+
+// validImage accepts only a base64 data: URL whose bytes really are the
+// raster format it claims.
+func validImage(src string) bool {
+ rest, ok := strings.CutPrefix(src, "data:image/")
+ if !ok {
+ return false
+ }
+ kind, data, ok := strings.Cut(rest, ";base64,")
+ if !ok {
+ return false
+ }
+ magics, ok := imageMagic[kind]
+ if !ok {
+ return false
+ }
+ raw, err := base64.StdEncoding.DecodeString(data)
+ if err != nil {
+ return false
+ }
+ if kind == "webp" && (len(raw) < 12 || string(raw[8:12]) != "WEBP") {
+ return false
+ }
+ for _, m := range magics {
+ if bytes.HasPrefix(raw, []byte(m)) {
+ return true
+ }
+ }
+ return false
+}
+
// ──────────────────────────────── html ───────────────────────────────────
type Page struct {
@@ -169,6 +218,10 @@ func fmtStamp(s *string) string {
return t.UTC().Format("2006-01-02 15:04 UTC")
}
+// lineBlock are the blocks printed as one "> " line; a run of them goes
+// together, after a blank line.
+var lineBlock = map[string]bool{"t": true, "d": true, "i": true, "v": true}
+
// RenderText is the transcript in plain text, in the order the terminal
// shows it with `claude-logbook -s`.
func RenderText(p *Payload) []byte {
@@ -208,14 +261,23 @@ func RenderText(p *Payload) []byte {
}
switch c.R {
case "d":
- if prev != "t" && prev != "d" {
+ if !lineBlock[prev] {
b.WriteString("\n")
}
fmt.Fprintf(&b, "> cd: %s\n", c.X)
case "c":
b.WriteString("\n--- context compacted here ---\n")
+ case "i", "v":
+ if !lineBlock[prev] {
+ b.WriteString("\n")
+ }
+ what := "image attached"
+ if c.R == "v" {
+ what = "image from the tool"
+ }
+ fmt.Fprintf(&b, "> %s\n", what)
case "t":
- if prev != "t" && prev != "d" {
+ if !lineBlock[prev] {
b.WriteString("\n")
}
fmt.Fprintf(&b, "> tool: %s\n", strings.ReplaceAll(c.X, "\n", " "))
diff --git a/server/server_test.go b/server/server_test.go
index 1ad36ee..9adfa3a 100644
--- a/server/server_test.go
+++ b/server/server_test.go
@@ -2,6 +2,7 @@ package main
import (
"bytes"
+ "encoding/base64"
"encoding/json"
"net/http"
"net/http/httptest"
@@ -145,6 +146,35 @@ func TestBranchAndDirectoryChange(t *testing.T) {
}
}
+func TestImages(t *testing.T) {
+ h := newHarness(t, nil)
+ png := "data:image/png;base64," + base64.StdEncoding.EncodeToString([]byte("\x89PNG\r\n\x1a\nrest"))
+ withImg := strings.Replace(session, `"c":[`, `"c":[{"r":"i","x":"[image]","src":"`+png+`"},{"r":"v","x":"[image]"},`, 1)
+ r := h.create(`{"payload":` + payload(withImg) + `}`)
+ if js := h.do("GET", "/share/"+r.ID+".json", "", "").Body.String(); !strings.Contains(js, png) {
+ t.Fatalf("image lost: %s", js)
+ }
+ txt := h.do("GET", "/share/"+r.ID+".txt", "", "").Body.String()
+ if !strings.Contains(txt, "> image attached\n> image from the tool\n") {
+ t.Fatalf("txt: %s", txt)
+ }
+
+ svg := "data:image/svg+xml;base64," + base64.StdEncoding.EncodeToString([]byte("<svg onload=alert(1)>"))
+ fake := "data:image/png;base64," + base64.StdEncoding.EncodeToString([]byte("<script>"))
+ for name, block := range map[string]string{
+ "svg": `{"r":"i","x":"","src":"` + svg + `"}`,
+ "wrong bytes": `{"r":"i","x":"","src":"` + fake + `"}`,
+ "not base64": `{"r":"i","x":"","src":"data:image/png;base64,%%%"}`,
+ "remote": `{"r":"i","x":"","src":"https://evil.test/x.png"}`,
+ "src on a text": `{"r":"u","x":"","src":"` + png + `"}`,
+ } {
+ bad := strings.Replace(session, `"c":[`, `"c":[`+block+`,`, 1)
+ if rec := h.do("POST", "/api/shares", `{"payload":`+payload(bad)+`}`, ""); rec.Code != 400 {
+ t.Errorf("%s: %d %s", name, rec.Code, rec.Body)
+ }
+ }
+}
+
func TestUpdateAndDeleteNeedTheSecret(t *testing.T) {
h := newHarness(t, nil)
r := h.create(`{"payload":` + payload() + `}`)