diff --git a/tests/test_session_routes.py b/tests/test_session_routes.py index 21fea947..0cb33eb7 100644 --- a/tests/test_session_routes.py +++ b/tests/test_session_routes.py @@ -36,7 +36,9 @@ async def _stub(_request: Request) -> JSONResponse: def _attach() -> AttachmentHandlers: - return AttachmentHandlers(upload=_stub, list=_stub, get_content=_stub, delete=_stub) + return AttachmentHandlers( + upload=_stub, list=_stub, get_content=_stub, thumbnail=_stub, delete=_stub + ) def _route_paths(routes: list[Any]) -> list[tuple[str, frozenset[str]]]: diff --git a/tests/test_thumbnails.py b/tests/test_thumbnails.py new file mode 100644 index 00000000..12f46ab5 --- /dev/null +++ b/tests/test_thumbnails.py @@ -0,0 +1,51 @@ +"""Tests for attachment thumbnail generation (image downscale + pdf first page).""" + +from __future__ import annotations + +from turnstone.core.thumbnails import make_thumbnail + +PNG_1x1 = ( + b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01" + b"\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\rIDATx\x9cc\xfc\xcf" + b"\xc0\xc0\xc0\x00\x00\x00\x05\x00\x01\xa5\xf6E@\x00\x00\x00\x00IEND\xaeB`\x82" +) +_PNG_MAGIC = b"\x89PNG\r\n\x1a\n" + + +def _minimal_pdf(text: str = "Hi") -> bytes: + stream = b"BT /F1 24 Tf 20 60 Td (" + text.encode("latin-1") + b") Tj ET" + objs = [ + b"<>", + b"<>", + b"<>>>>>", + b"<>\nstream\n%s\nendstream" % (len(stream), stream), + b"<>", + ] + pdf = b"%PDF-1.4\n" + offsets = [] + for i, obj in enumerate(objs, 1): + offsets.append(len(pdf)) + pdf += b"%d 0 obj\n%s\nendobj\n" % (i, obj) + xref = len(pdf) + pdf += b"xref\n0 %d\n0000000000 65535 f \n" % (len(objs) + 1) + for off in offsets: + pdf += b"%010d 00000 n \n" % off + pdf += b"trailer\n<>\nstartxref\n%d\n%%%%EOF" % (len(objs) + 1, xref) + return pdf + + +class TestMakeThumbnail: + def test_image_thumbnail_is_png(self) -> None: + out = make_thumbnail(PNG_1x1, "image") + assert out is not None and out[:8] == _PNG_MAGIC + + def test_pdf_thumbnail_is_png(self) -> None: + out = make_thumbnail(_minimal_pdf(), "pdf") + assert out is not None and out[:8] == _PNG_MAGIC + + def test_audio_has_no_thumbnail(self) -> None: + assert make_thumbnail(b"RIFFfake", "audio") is None + + def test_garbage_image_returns_none(self) -> None: + assert make_thumbnail(b"not an image", "image") is None diff --git a/turnstone/core/session_routes.py b/turnstone/core/session_routes.py index 430d70b4..d203bb04 100644 --- a/turnstone/core/session_routes.py +++ b/turnstone/core/session_routes.py @@ -483,6 +483,7 @@ class AttachmentHandlers: upload: Handler # POST {prefix}/{ws_id}/attachments list: Handler # GET {prefix}/{ws_id}/attachments get_content: Handler # GET {prefix}/{ws_id}/attachments/{attachment_id}/content + thumbnail: Handler # GET {prefix}/{ws_id}/attachments/{attachment_id}/thumbnail delete: Handler # DELETE {prefix}/{ws_id}/attachments/{attachment_id} @@ -630,6 +631,13 @@ def register_session_routes( methods=["GET"], ) ) + routes.append( + Route( + f"{p}/{{ws_id}}/attachments/{{attachment_id}}/thumbnail", + a.thumbnail, + methods=["GET"], + ) + ) routes.append( Route( f"{p}/{{ws_id}}/attachments/{{attachment_id}}", @@ -3816,10 +3824,18 @@ def make_attachment_handlers(cfg: SessionEndpointConfig) -> AttachmentHandlers: ] return JSONResponse({"attachments": rows}) - async def get_content(request: Request) -> Response: - import asyncio + async def _resolve_served_blob( + request: Request, + ) -> tuple[bytes, str, str, str] | Response: + """Gate + resolve an attachment blob for serving (content or thumbnail). - from starlette.responses import Response as _Response + Returns ``(body, kind, mime, filename)`` or an error ``Response``. + Pending (staged) blobs serve from the buffer scoped to the uploader; + committed blobs serve from the store gated by ownership — the requester + (already gated to own ``ws_id``) must have a turn whose ref-list names the + id. Cross-user / cross-ws / unreferenced → 404 so existence doesn't leak. + """ + import asyncio from turnstone.core.attachment_buffer import get_attachment_buffer from turnstone.core.memory import attachment_referenced_in_ws, get_attachment @@ -3834,38 +3850,38 @@ def make_attachment_handlers(cfg: SessionEndpointConfig) -> AttachmentHandlers: user_id, err = await _resolve_owner(request, ws_id) if err: return err - - # Pending (staged) blobs serve straight from the buffer, scoped to the - # uploader. Committed blobs serve from the store, gated by ownership: - # the requester (already gated to own ``ws_id``) must have a turn whose - # ref-list names the id. Cross-user / cross-ws / unreferenced → 404 so - # existence doesn't leak. - kind: str - stored_mime: str - filename: str staged = get_attachment_buffer().get(attachment_id, ws_id=ws_id, user_id=user_id) if staged is not None: - body: bytes = staged.content - kind = staged.kind - stored_mime = staged.mime_type or "application/octet-stream" - filename = staged.filename or "attachment" - else: - # Both committed-blob gates are sync DB I/O — the ref check is an - # unbounded ws-scoped LIKE scan (O(turns-in-ws)) run on every - # committed-image request, so keep it off the event loop. Matches - # the asyncio.to_thread convention used throughout this module. - row = await asyncio.to_thread(get_attachment, attachment_id) - if not row or not await asyncio.to_thread( - attachment_referenced_in_ws, attachment_id, ws_id - ): - return JSONResponse({"error": "Not found"}, status_code=404) - body = row.get("content") or b"" - kind = row.get("kind") or "" - stored_mime = row.get("mime_type") or "application/octet-stream" - filename = str(row.get("filename") or "attachment") + return ( + staged.content, + staged.kind, + staged.mime_type or "application/octet-stream", + staged.filename or "attachment", + ) + # Committed-blob gates are sync DB I/O — the ref check is an unbounded + # ws-scoped LIKE scan (O(turns-in-ws)), so keep it off the event loop. + row = await asyncio.to_thread(get_attachment, attachment_id) + if not row or not await asyncio.to_thread( + attachment_referenced_in_ws, attachment_id, ws_id + ): + return JSONResponse({"error": "Not found"}, status_code=404) + return ( + row.get("content") or b"", + row.get("kind") or "", + row.get("mime_type") or "application/octet-stream", + str(row.get("filename") or "attachment"), + ) + + async def get_content(request: Request) -> Response: + from starlette.responses import Response as _Response + + resolved = await _resolve_served_blob(request) + if not isinstance(resolved, tuple): + return resolved + body, kind, stored_mime, filename = resolved # Force text/plain for text kinds — avoids same-origin HTML/SVG - # rendering if a user uploaded an HTML-ish text file. Images - # keep their sniffed MIME (allowlist is strict: png/jpeg/gif/webp). + # rendering if a user uploaded an HTML-ish text file. Images keep their + # sniffed MIME (allowlist is strict: png/jpeg/gif/webp). response_mime = "text/plain; charset=utf-8" if kind == "text" else stored_mime safe_name = filename.replace('"', "").replace("\r", "").replace("\n", "") headers = { @@ -3876,6 +3892,32 @@ def make_attachment_handlers(cfg: SessionEndpointConfig) -> AttachmentHandlers: } return _Response(body, media_type=response_mime, headers=headers) + async def get_thumbnail(request: Request) -> Response: + import asyncio + + from starlette.responses import Response as _Response + + from turnstone.core.thumbnails import make_thumbnail + + resolved = await _resolve_served_blob(request) + if not isinstance(resolved, tuple): + return resolved + body, kind, _mime, _filename = resolved + if kind not in ("image", "pdf"): + return JSONResponse({"error": "no thumbnail for this attachment kind"}, status_code=415) + png = await asyncio.to_thread(make_thumbnail, body, kind) + if png is None: + return JSONResponse({"error": "thumbnail unavailable"}, status_code=415) + return _Response( + png, + media_type="image/png", + headers={ + "X-Content-Type-Options": "nosniff", + "Content-Security-Policy": "default-src 'none'; sandbox", + "Cache-Control": "private, max-age=300", + }, + ) + async def delete_(request: Request) -> Response: from turnstone.core.attachment_buffer import get_attachment_buffer @@ -3900,6 +3942,7 @@ def make_attachment_handlers(cfg: SessionEndpointConfig) -> AttachmentHandlers: upload=upload, list=list_pending, get_content=get_content, + thumbnail=get_thumbnail, delete=delete_, ) diff --git a/turnstone/core/thumbnails.py b/turnstone/core/thumbnails.py new file mode 100644 index 00000000..ed9223a4 --- /dev/null +++ b/turnstone/core/thumbnails.py @@ -0,0 +1,46 @@ +"""Small PNG thumbnails of visual attachments, for the UI chip/preview. + +``image`` → downscaled PNG; ``pdf`` → first page rendered (pypdfium2) then +downscaled. Audio and text have no thumbnail. Never raises — returns ``None`` on +any failure, and the UI falls back to a plain icon. +""" + +from __future__ import annotations + +from io import BytesIO + +from turnstone.core.log import get_logger + +log = get_logger(__name__) + +_THUMB_MAX_PX = 160 + + +def make_thumbnail(data: bytes, kind: str, *, max_px: int = _THUMB_MAX_PX) -> bytes | None: + """Return a small PNG thumbnail for an ``image``/``pdf`` blob, else ``None``.""" + try: + from PIL import Image + except ImportError: # pragma: no cover - declared dependency; defensive + log.warning("Pillow not installed; thumbnails unavailable") + return None + + try: + if kind == "pdf": + from turnstone.core.pdf import rasterize_pdf + + pages = rasterize_pdf(data, max_pages=1) + if not pages: + return None + img = Image.open(BytesIO(pages[0])) + elif kind == "image": + img = Image.open(BytesIO(data)) + else: + return None + rgb = img.convert("RGB") + rgb.thumbnail((max_px, max_px)) + buf = BytesIO() + rgb.save(buf, format="PNG") + return buf.getvalue() + except Exception as exc: + log.warning("thumbnail generation failed (kind=%s): %s", kind, exc) + return None diff --git a/turnstone/shared_static/chat.css b/turnstone/shared_static/chat.css index f82e115f..01fb1996 100644 --- a/turnstone/shared_static/chat.css +++ b/turnstone/shared_static/chat.css @@ -66,6 +66,47 @@ color: var(--red); } +/* --- attachment previews (image/pdf thumbnail · audio player · text snippet) --- */ +.composer-chip { + flex-wrap: wrap; + max-width: 340px; +} +.attach-preview-thumb { + width: 28px; + height: 28px; + object-fit: cover; + border-radius: var(--radius-sm); + border: 1px solid var(--border); + background: var(--bg-base); + vertical-align: middle; + flex: 0 0 auto; +} +.attach-preview-audio { + height: 30px; + max-width: 240px; + vertical-align: middle; + flex: 1 1 auto; +} +.attach-preview-snippet { + display: inline-block; + color: var(--fg-dim); + font-size: 10px; + max-width: 240px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + vertical-align: middle; + flex: 1 1 auto; +} +.msg-user-attach-pill .attach-preview-thumb { + width: 44px; + height: 44px; +} +.msg-user-attach-pill .attach-preview-audio { + height: 32px; + max-width: 280px; +} + .composer-row { display: flex; align-items: flex-end; diff --git a/turnstone/shared_static/composer_attachments.js b/turnstone/shared_static/composer_attachments.js index eaa4bd3a..dbdfa4f9 100644 --- a/turnstone/shared_static/composer_attachments.js +++ b/turnstone/shared_static/composer_attachments.js @@ -47,6 +47,82 @@ function _inferKind(file) { return "text"; } +function _attachUrl(wsId, id, suffix) { + return ( + "/v1/api/workstreams/" + + encodeURIComponent(wsId) + + "/attachments/" + + encodeURIComponent(id) + + suffix + ); +} + +function _kindIcon(kind) { + if (kind === "image") return "🖼"; + if (kind === "audio") return "🎵"; + return "📄"; // pdf + text +} + +// Build an inline preview node for a committed attachment (real id), or null. +// image/pdf → server-rendered thumbnail; audio →