aboutsummaryrefslogtreecommitdiffstats
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
parentf546a8a1578bef9fbc144580b84f51e3e90480b1 (diff)
downloadclaude-logbook-80ea7b675e918b930af01b82b60583d474bc0105.tar.gz
claude-logbook-80ea7b675e918b930af01b82b60583d474bc0105.zip
Share and export a session's images with --imagesHEADmain
-rw-r--r--README.es.md12
-rw-r--r--README.md11
-rw-r--r--claude_logbook/__init__.py2
-rw-r--r--claude_logbook/cli.py30
-rw-r--r--claude_logbook/sessions.py57
-rw-r--r--claude_logbook/template.html34
-rw-r--r--claude_logbook/terminal.py7
-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
-rw-r--r--tests/test_images.py82
-rw-r--r--tests/test_terminal.py12
14 files changed, 339 insertions, 22 deletions
diff --git a/README.es.md b/README.es.md
index fe976a0..f8749a2 100644
--- a/README.es.md
+++ b/README.es.md
@@ -95,6 +95,7 @@ claude-logbook docker -s 2 # lee el 2º de esos tres
| `--html [FILE]` | Genera la página autocontenida con la misma selección que `--json` (por defecto `sessions.html`, o `session-<id>.html` con `-s`). |
| `--template FILE` | Usa tu propio template para `--html`. |
| `--open` | Abre en el navegador lo que haya generado `--html`. |
+| `--images` | Con `-s`: incluye las imágenes de la sesión en `--html`, `--json` o `--share`. |
| `--no-cache` | Ignora el caché y re-parsea todo. |
| `-m`, `--memory` | Trabaja sobre las memorias en vez de las sesiones. |
| `--type TYPE` | Con `-m`: filtra por `project`, `user`, `feedback` o `reference`. |
@@ -224,6 +225,7 @@ su link:
```bash
claude-logbook -s 3 --share # muestra qué se sube y pregunta
claude-logbook -s 3 --share --expire 7d # desaparece en una semana (default 30 días)
+claude-logbook -s 3 --share --images # con las capturas e imágenes
claude-logbook -s 3 --share # otra vez más tarde: mismo link, contenido nuevo
claude-logbook --shares # qué compartiste y hasta cuándo
claude-logbook --unshare 5d10f1ee # bajarlo (id, URL o sesión)
@@ -250,6 +252,10 @@ Para tener en cuenta:
devuelve un secret que se guarda en
`$XDG_STATE_HOME/claude-logbook/shares.json` (`~/.local/state/...`), con modo
600. Si perdés ese archivo, el share queda hasta que vence.
+- **Las imágenes van solo con `--images`**: las que pegaste y las que una
+ herramienta le dio a Claude (una captura, una imagen que leyó). El aviso no
+ mira dentro de ellas, así que revisalas antes. El servidor acepta hasta 20 MB
+ por share.
- Las memorias nunca se comparten.
El servidor está en [`server/`](server/README.md): Go, solo biblioteca estándar,
@@ -309,6 +315,10 @@ Algunos detalles que conviene saber:
muestra colapsada bajo "Inherited history", y solo lo que vino después cuenta
para los mensajes, la duración y la fecha de inicio. En la tabla la sesión
lleva un chip `branch`.
+- **Imágenes.** La transcripción muestra dónde estaba cada una (`▣ image
+ attached`, `▣ image from the tool`). Las imágenes en sí van solo en el
+ `--html`, `--json` o `--share` de una sesión, con `--images`: PNG, JPEG, GIF y
+ WebP, nunca SVG.
- **Cambios de directorio.** Cuando la sesión pasa a otro directorio (un `cd`
que queda, `/cd`, un worktree) la transcripción muestra ahí `→ /ruta/nueva`, y
`-p` también encuentra la sesión por cualquiera de los directorios donde trabajó.
@@ -360,7 +370,7 @@ Cada sesión de `s`:
| `u` / `a` | Cantidad de mensajes tuyos / de Claude. En una rama, solo su parte propia. |
| `k` | Tamaño del archivo en KB. |
| `v` | Versión de Claude Code. |
-| `c` | Transcripción: `[{"r": "u"|"a"|"t"|"c"|"d", "x": texto}]`; `t` es una llamada a herramienta, `c` un resumen de compactación, `d` un cambio de directorio de trabajo. |
+| `c` | Transcripción: `[{"r": "u"|"a"|"t"|"c"|"d"|"i"|"v", "x": texto}]`; `t` es una llamada a herramienta, `c` un resumen de compactación, `d` un cambio de directorio de trabajo, `i` una imagen que adjuntaste y `v` una que una herramienta le dio a Claude. Con `--images` esas dos llevan `src`, la imagen como URL `data:`. |
| `h` | Cuántos bloques al principio de `c` se heredaron de la sesión de la que salió esta rama (0 si no es una rama). |
| `o` / `ot` | Id y título de esa sesión (`null` si no es una rama; `ot` también si ya no está en disco). |
diff --git a/README.md b/README.md
index b28d62b..1f92cfa 100644
--- a/README.md
+++ b/README.md
@@ -95,6 +95,7 @@ claude-logbook docker -s 2 # reads the 2nd of those three
| `--html [FILE]` | Build the standalone page with the same selection as `--json` (default `sessions.html`, or `session-<id>.html` with `-s`). |
| `--template FILE` | Use your own template for `--html`. |
| `--open` | Open whatever `--html` produced in your browser. |
+| `--images` | With `-s`: include the session's images in `--html`, `--json` or `--share`. |
| `--no-cache` | Ignore the cache and re-parse everything. |
| `-m`, `--memory` | Work on memories instead of sessions. |
| `--type KIND` | With `-m`: filter by `project`, `user`, `feedback` or `reference`. |
@@ -223,6 +224,7 @@ prints its link:
```bash
claude-logbook -s 3 --share # shows what goes up and asks
claude-logbook -s 3 --share --expire 7d # gone in a week (default 30 days)
+claude-logbook -s 3 --share --images # with the screenshots and pictures
claude-logbook -s 3 --share # again later: same link, new content
claude-logbook --shares # what you have shared, and until when
claude-logbook --unshare 5d10f1ee # take it down (id, URL or session)
@@ -249,6 +251,9 @@ What to keep in mind:
`$XDG_STATE_HOME/claude-logbook/shares.json` (`~/.local/state/...`), mode
600. Lose that file and the share stays until it expires.
- Memories are never shared.
+- **Images go only with `--images`**: the ones you pasted and the ones a tool
+ handed to Claude (a screenshot, an image it read). The warning does not look
+ inside them, so check them first. The server takes up to 20 MB per share.
The server lives in [`server/`](server/README.md): Go, standard library only,
a `FROM scratch` Docker image. You can run your own and point
@@ -309,6 +314,10 @@ A few details worth knowing:
shown collapsed under "Inherited history", and only what came after counts
towards the messages, the duration and the start date. In the table the
session carries a `branch` chip.
+- **Images.** The transcript shows where each one was (`▣ image attached`,
+ `▣ image from the tool`). The pictures themselves only go into a single
+ session's `--html`, `--json` or `--share` with `--images`: PNG, JPEG, GIF
+ and WebP, never SVG.
- **Directory changes.** When the session moves to another directory (a `cd`
that sticks, `/cd`, a worktree) the transcript shows `→ /new/path` there, and
`-p` also finds the session by any directory it worked in.
@@ -358,7 +367,7 @@ Each session in `s`:
| `u` / `a` | Message counts, yours / Claude's. In a branch, only its own part. |
| `k` | File size in KB. |
| `v` | Claude Code version. |
-| `c` | Transcript: `[{"r": "u"|"a"|"t"|"c"|"d", "x": text}]`; `t` is a tool call, `c` a compaction summary, `d` a change of working directory. |
+| `c` | Transcript: `[{"r": "u"|"a"|"t"|"c"|"d"|"i"|"v", "x": text}]`; `t` is a tool call, `c` a compaction summary, `d` a change of working directory, `i` an image you attached and `v` one a tool gave Claude. With `--images` those two carry `src`, the picture as a `data:` URL. |
| `h` | How many blocks at the start of `c` were inherited from the session this one branched from (0 if not a branch). |
| `o` / `ot` | Id and title of that session (`null` if not a branch; `ot` also if it is no longer on disk). |
diff --git a/claude_logbook/__init__.py b/claude_logbook/__init__.py
index 334c56b..faa8544 100644
--- a/claude_logbook/__init__.py
+++ b/claude_logbook/__init__.py
@@ -3,6 +3,6 @@
No dependencies: standard library only.
"""
-__version__ = "1.6.0"
+__version__ = "1.7.0"
__all__ = ["__version__"]
diff --git a/claude_logbook/cli.py b/claude_logbook/cli.py
index 64c6c01..0802507 100644
--- a/claude_logbook/cli.py
+++ b/claude_logbook/cli.py
@@ -10,8 +10,8 @@ import webbrowser
from . import __version__
from .sessions import (
- SessionError, apply_filters, default_root, drop_from_cache, latest_activity,
- load_sessions, pick, public_records, session_path,
+ SessionError, apply_filters, count_images, default_root, drop_from_cache,
+ latest_activity, load_sessions, pick, public_records, session_path, with_images,
)
from .terminal import (
Style, clip, fmt_date, fmt_size, plural, print_audit, print_chat,
@@ -77,6 +77,7 @@ user settings unless -p names a project, then to its settings.local.json;
sharing (one session, readable by anyone with the link; asks first unless -y):
claude-logbook -s 3 --share upload #3, prints its link
claude-logbook -s 3 --share --expire 7d gone in a week (default 30d)
+ claude-logbook -s 3 --share --images with the screenshots and pictures
claude-logbook --shares what this machine has shared
claude-logbook --unshare 5d10f1ee delete it (share id, URL or session)
"""
@@ -149,6 +150,9 @@ def build_parser():
help="use another template for --html")
output_path.add_argument("--open", action="store_true",
help="open what --html writes in the browser")
+ output_path.add_argument("--images", action="store_true",
+ help="with -s: include the session's images in --html, "
+ "--json or --share")
sharing = ap.add_argument_group("share")
sharing.add_argument("--share", action="store_true",
@@ -296,7 +300,8 @@ def export_selection(sessions, args):
only the memories of the projects that made it. A single session (-s) goes
alone, as it would be shared: no memories."""
if args.show:
- return [pick(filtered(sessions, args), args.show)], []
+ s = pick(filtered(sessions, args), args.show)
+ return [with_images(s) if args.images else s], []
else:
chosen = filtered(sessions, args)
if args.limit:
@@ -598,6 +603,9 @@ def cmd_share(sessions, args, st):
if s["e"]:
raise SessionError("that session has no messages, there is nothing to share")
server = share.server_url(args.server)
+ if args.images:
+ s = with_images(s)
+ images, with_src = count_images(s)
payload = webpage.build_payload(public_records([s]), [])
size = len(json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode())
old = share.entry_for_session(share.load_registry(), s["id"], server)
@@ -606,6 +614,12 @@ def cmd_share(sessions, args, st):
print(f"{st.bold}{s['t'] or 'Untitled session'}{st.reset}", file=err)
print(f" {short_home(s['p'])} · {s['u']} yours / {s['a']} from Claude · "
f"{len(s['c'])} blocks · {fmt_size(size / 1024)}", file=err)
+ if with_src:
+ print(f" {plural(with_src, 'image', 'images')} included; they are not "
+ f"checked for secrets, look at them first", file=err)
+ elif images:
+ print(f" {plural(images, 'image', 'images')} left out (add --images "
+ f"to include them)", file=err)
where = f"updates {old['url']}" if old else f"new link on {server}"
print(f" {where}, expires in {args.expire}", file=err)
for kind, blocks in share.scan_secrets(payload):
@@ -623,7 +637,12 @@ def cmd_share(sessions, args, st):
print("Nothing was uploaded.", file=err)
return 1
- entry, created = share.publish(server, payload, args.expire, s["id"], s["t"])
+ try:
+ entry, created = share.publish(server, payload, args.expire, s["id"], s["t"])
+ except SessionError as e:
+ if with_src and "too large" in str(e):
+ raise SessionError(f"{e}; try it without --images") from None
+ raise
print(f"{'Shared' if created else 'Updated'} · expires {fmt_expiry(entry)}", file=err)
print(entry["url"])
print(f"{st.faint} {entry['url']}.txt · {entry['url']}.json{st.reset}", file=err)
@@ -684,6 +703,9 @@ def run(args):
if args.memory:
return run_memory(sessions, args, st)
+ if args.images and not args.show:
+ raise SessionError("--images needs a session (-s REF)")
+
if args.share:
return cmd_share(sessions, args, st)
diff --git a/claude_logbook/sessions.py b/claude_logbook/sessions.py
index 75db116..362b398 100644
--- a/claude_logbook/sessions.py
+++ b/claude_logbook/sessions.py
@@ -19,9 +19,11 @@ Schema of the record returned by `read_session`:
u/a number of messages from you / from Claude
k size of the .jsonl in KB
v Claude Code version
- c transcript: [{"r": "u" | "a" | "t" | "c" | "d", "x": text}]
+ c transcript: [{"r": "u" | "a" | "t" | "c" | "d" | "i" | "v", "x": text}]
(t: tool call summary, c: compaction summary, d: the working
- directory changed to x)
+ directory changed to x, i: an image you attached, v: an image a
+ tool gave Claude). Image blocks carry the picture itself in "src",
+ as a data: URL, only when read with `images=True`
h how many blocks at the start of `c` were copied from the session
this one branched from (/branch); 0 if it is not a branch
o id of that session, or None
@@ -42,7 +44,7 @@ from datetime import datetime, timezone
# Bump it when the record schema changes: it invalidates old caches instead of
# reading records with the previous shape.
-CACHE_VERSION = 4
+CACHE_VERSION = 5
EPOCH = datetime(1970, 1, 1, tzinfo=timezone.utc)
@@ -136,6 +138,30 @@ def tool_summary(block):
return f"{name}: {val}" if val else name
+# The formats a browser shows that cannot carry a script (so no SVG).
+IMAGE_TYPES = ("image/png", "image/jpeg", "image/gif", "image/webp")
+
+
+def image_src(block):
+ """data: URL of an image block, or None if it is not one we can show."""
+ src = block.get("source")
+ if not isinstance(src, dict) or src.get("type") != "base64":
+ return None
+ kind, data = src.get("media_type"), src.get("data")
+ if kind not in IMAGE_TYPES or not isinstance(data, str) or not data:
+ return None
+ return f"data:{kind};base64,{data}"
+
+
+def image_block(role, block, images):
+ out = {"r": role, "x": "[image]"}
+ if images:
+ src = image_src(block)
+ if src:
+ out["src"] = src
+ return out
+
+
def blocks_of(message):
content = message.get("content")
if isinstance(content, str):
@@ -167,8 +193,10 @@ def active_minutes(stamps):
return round(total)
-def read_session(path):
- """Parses a whole .jsonl and returns that session's record."""
+def read_session(path, images=False):
+ """Parses a whole .jsonl and returns that session's record. With images
+ the image blocks bring the pictures, which can weigh megabytes: that
+ record is for a single export, never for the cache."""
session_id = os.path.basename(path)[:-6] # without .jsonl
first_ts = last_ts = cwd = here = git_branch = version = None
origin = None # the session /branch copied the history from
@@ -263,7 +291,12 @@ def read_session(path):
fallback_title = text[:TITLE_MAX]
convo.append({"r": "u", "x": text})
elif b.get("type") == "image":
- convo.append({"r": "u", "x": "[imagen adjunta]"})
+ convo.append(image_block("i", b, images))
+ elif b.get("type") == "tool_result" and isinstance(b.get("content"), list):
+ # What a tool handed back to Claude: a screenshot, an image it read.
+ for part in b["content"]:
+ if isinstance(part, dict) and part.get("type") == "image":
+ convo.append(image_block("v", part, images))
else:
counted = False
for b in blocks_of(message):
@@ -417,6 +450,18 @@ def load_sessions(root=None, cache_path=None, use_cache=True):
return sessions
+def with_images(s, root=None):
+ """The same record with its pictures, read again from its .jsonl."""
+ full = read_session(session_path(s, root), images=True)
+ return {**s, "c": full["c"]}
+
+
+def count_images(s):
+ """(images, of which with the picture) in a record."""
+ imgs = [m for m in s["c"] if m["r"] in ("i", "v")]
+ return len(imgs), sum(1 for m in imgs if m.get("src"))
+
+
def latest_activity(sessions):
"""The most recent instant in the data: the "now" relative dates are computed
against, so they do not depend on the viewer's clock."""
diff --git a/claude_logbook/template.html b/claude_logbook/template.html
index 39740ac..85edcb4 100644
--- a/claude_logbook/template.html
+++ b/claude_logbook/template.html
@@ -561,6 +561,25 @@
border-bottom: 1px dashed var(--line);
}
+ .shot { margin: 8px 0 8px 12px; }
+ .shot.mine { margin-left: 0; }
+ .shot.mine img { border-color: var(--amber-lo); }
+ .shot img {
+ display: block;
+ max-width: min(100%, 360px);
+ max-height: 260px;
+ border: 1px solid var(--line);
+ border-radius: 8px;
+ cursor: zoom-in;
+ }
+ .shot.zoom img { max-width: 100%; max-height: none; cursor: zoom-out; }
+ .shot-missing {
+ font-family: var(--mono);
+ font-size: 11.5px;
+ color: var(--muted);
+ padding: 3px 0 3px 12px;
+ }
+
.cwd {
font-family: var(--mono);
font-size: 11.5px;
@@ -1216,11 +1235,24 @@ function showReader() {
if (!SINGLE) readerEl.focus();
}
+// Only a data: URL of a raster image ever reaches an <img>: the server checks
+// it, and this checks it again for pages written by hand.
+const IMG_RE = /^data:image\/(png|jpeg|gif|webp);base64,[A-Za-z0-9+/=\s]+$/;
+
+function imageHtml(m) {
+ const what = m.r === 'i' ? 'image attached' : 'image from the tool';
+ if (!m.src || !IMG_RE.test(m.src))
+ return `<div class="shot-missing">▣ ${what}${m.src ? '' : ' (not included)'}</div>`;
+ return `<div class="shot${m.r === 'i' ? ' mine' : ''}"><img src="${m.src}" alt="${what}" title="${what}" loading="lazy"></div>`;
+}
+
function blockHtml(m) {
return m.r === 't'
? `<div class="tool"><b>⚒</b><span>${esc(m.x)}</span></div>`
: m.r === 'd'
? `<div class="cwd">→ ${esc(m.x)}</div>`
+ : m.r === 'i' || m.r === 'v'
+ ? imageHtml(m)
: m.r === 'c'
? `<details class="compact"><summary>Context compacted here · the summary Claude kept</summary><div class="bubble">${renderBody(m.x)}</div></details>`
: `<div class="turn turn-${m.r}"><span class="who">${WHO[m.r]}</span><div class="bubble">${renderBody(m.x)}</div></div>`;
@@ -1392,6 +1424,8 @@ rowsEl.addEventListener('keydown', e => {
// Inside the reader: [[...]] links between memories and jump to the source session.
bodyEl.addEventListener('click', e => {
+ const shot = e.target.closest('.shot');
+ if (shot) { shot.classList.toggle('zoom'); return; }
const wl = e.target.closest('[data-wl]');
if (wl) { openMemory(wl.dataset.wl); return; }
const jump = e.target.closest('[data-chat]');
diff --git a/claude_logbook/terminal.py b/claude_logbook/terminal.py
index 351c9bf..1731f14 100644
--- a/claude_logbook/terminal.py
+++ b/claude_logbook/terminal.py
@@ -236,6 +236,13 @@ def print_chat(s, st, out, show_tools=True, width=None):
first = False
divider(" context compacted here ")
continue
+ if m["r"] in ("i", "v"):
+ # An image cannot be printed here: only that it was there.
+ if m["r"] == "i" or show_tools:
+ what = "image attached" if m["r"] == "i" else "image from the tool"
+ print(f" {st.faint}▣ {what}{st.reset}", file=out)
+ first = False
+ continue
if m["r"] == "d":
print(f" {st.faint}→ {clip(m['x'], body - 4)}{st.reset}", file=out)
first = False
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() + `}`)
diff --git a/tests/test_images.py b/tests/test_images.py
new file mode 100644
index 0000000..546be0e
--- /dev/null
+++ b/tests/test_images.py
@@ -0,0 +1,82 @@
+import base64
+import json
+import os
+
+from claude_logbook import sessions as S
+
+from .fixtures import assistant, user, write_session
+from .test_share import ShareCase
+
+PNG = base64.b64encode(b"\x89PNG\r\n\x1a\nfake").decode()
+SRC = "data:image/png;base64," + PNG
+
+
+def image(kind="image/png", data=PNG):
+ return {"type": "image", "source": {"type": "base64", "media_type": kind, "data": data}}
+
+
+def pasted(*blocks, **extra):
+ ev = user("x", **extra)
+ ev["message"]["content"] = list(blocks)
+ return ev
+
+
+class TestImages(ShareCase):
+ ID = "eeeeeeee-0000-0000-0000-000000000001"
+
+ def setUp(self):
+ super().setUp()
+ tool_result = {"type": "tool_result", "tool_use_id": "t1", "content": [
+ {"type": "text", "text": "read it"}, image("image/jpeg")]}
+ self.path = write_session(self.root, "-home-u-proj", self.ID, [
+ pasted({"type": "text", "text": "look at this"}, image()),
+ assistant("Reading the other one.", tools=[("Read", {"file_path": "/x/a.jpg"})]),
+ pasted(tool_result),
+ pasted(image("image/svg+xml", "PHN2Zz4=")),
+ ])
+
+ def test_the_record_has_no_pictures(self):
+ rec = S.read_session(self.path)
+ self.assertEqual([m["r"] for m in rec["c"]], ["u", "i", "a", "t", "v", "i"])
+ self.assertFalse(any("src" in m for m in rec["c"]))
+ self.assertEqual(rec["u"], 1)
+ self.assertEqual(S.count_images(rec), (3, 0))
+
+ def test_with_images_brings_only_raster_ones(self):
+ rec = S.read_session(self.path, images=True)
+ srcs = [m.get("src") for m in rec["c"] if m["r"] in "iv"]
+ self.assertEqual(srcs[0], SRC)
+ self.assertTrue(srcs[1].startswith("data:image/jpeg;base64,"))
+ self.assertIsNone(srcs[2]) # svg is not shown
+ self.assertEqual(S.count_images(rec), (3, 2))
+
+ def test_json_and_html_only_with_the_flag(self):
+ _, out, _ = self.run_cli("-s", "eeeeeeee", "--json")
+ self.assertNotIn(PNG, out)
+ _, out, _ = self.run_cli("-s", "eeeeeeee", "--json", "--images")
+ self.assertIn(SRC, json.dumps(json.loads(out)))
+ page = os.path.join(self.home, "p.html")
+ self.run_cli("-s", "eeeeeeee", "--html", page, "--images")
+ with open(page, encoding="utf-8") as f:
+ self.assertIn(PNG, f.read())
+
+ def test_images_need_a_session(self):
+ code, _, err = self.run_cli("--json", "--images")
+ self.assertEqual(code, 2)
+ self.assertIn("needs a session", err)
+
+ def test_share_leaves_them_out_unless_asked(self):
+ _, _, err = self.run_cli("-s", "eeeeeeee", "--share", "-y")
+ self.assertIn("3 images left out", err)
+ body = self.fake.requests[-1][2]
+ self.assertNotIn(PNG, json.dumps(body))
+
+ _, _, err = self.run_cli("-s", "eeeeeeee", "--share", "-y", "--images")
+ self.assertIn("2 images included", err)
+ self.assertIn(SRC, json.dumps(self.fake.requests[-1][2]))
+
+ def test_too_large_suggests_without_images(self):
+ self.fake.force = (413, "payload larger than 20971520 bytes")
+ code, _, err = self.run_cli("-s", "eeeeeeee", "--share", "-y", "--images")
+ self.assertEqual(code, 2)
+ self.assertIn("try it without --images", err)
diff --git a/tests/test_terminal.py b/tests/test_terminal.py
index 7752e37..c9321e7 100644
--- a/tests/test_terminal.py
+++ b/tests/test_terminal.py
@@ -147,6 +147,18 @@ class TestOutput(unittest.TestCase):
self.assertIn("→ /home/u/proj/sub", out)
self.assertIn("new one", out)
+ def test_images_are_a_mark(self):
+ c = [{"r": "u", "x": "look"}, {"r": "i", "x": "[image]", "src": "data:x"},
+ {"r": "v", "x": "[image]"}]
+ buf = io.StringIO()
+ T.print_chat(self.session_item(c=c), T.Style(False), buf)
+ self.assertIn("▣ image attached", buf.getvalue())
+ self.assertIn("▣ image from the tool", buf.getvalue())
+ self.assertNotIn("data:x", buf.getvalue())
+ buf = io.StringIO()
+ T.print_chat(self.session_item(c=c), T.Style(False), buf, show_tools=False)
+ self.assertNotIn("from the tool", buf.getvalue())
+
def test_empty_session_says_so(self):
buf = io.StringIO()
T.print_chat(self.session_item(c=[], t=None), T.Style(False), buf)