From 0ca244be3deb0608adcf0d6c3197d1801c34fd52 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 22 Jul 2026 23:12:21 -0400 Subject: [PATCH] =?UTF-8?q?Sync=205:=20attachment=20blob=20sync=20?= =?UTF-8?q?=E2=80=94=20client=20id=20+=20content=20hash=20(M8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Let native clients sync attachment blobs deterministically: - note_attachments gains sha256 (migration 0018, nullable, no backfill). The delta feed's attachment metadata now carries size + sha256 so a client knows exactly which blobs it already has (dedupe) and can verify integrity after download. - Upload accepts an optional client-supplied attachment id (multipart form field), so a file attached offline keeps its identity across sync; re-uploading an id the note already has is an idempotent no-op. The server hashes the stored bytes (sha256) on upload. Download by id already exists (owner/shared scoped). Frontend Attachment type carries the new optional size/sha256. (Still image-only mimes — broadening to any-file is task 1900. Blob sync behavior is operator-verified on deploy.) Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm --- alembic/versions/0018_attachment_sha256.py | 25 ++++++++++++++++ frontend/src/stores/notes.ts | 2 ++ src/thoughtsync/models/note_attachment.py | 3 ++ src/thoughtsync/notes.py | 35 ++++++++++++++++++++-- 4 files changed, 63 insertions(+), 2 deletions(-) create mode 100644 alembic/versions/0018_attachment_sha256.py diff --git a/alembic/versions/0018_attachment_sha256.py b/alembic/versions/0018_attachment_sha256.py new file mode 100644 index 0000000..56cbfce --- /dev/null +++ b/alembic/versions/0018_attachment_sha256.py @@ -0,0 +1,25 @@ +"""note_attachments.sha256 (M8 sync hub, step 5 — attachment blob sync) + +Revision ID: 0018 +Revises: 0017 +Create Date: 2026-07-23 + +A content hash so a native client can tell which attachment blobs it already has +(dedupe) and verify integrity after downloading. Nullable; existing rows are left +un-hashed (no backfill — the file bytes may not be reachable at migration time). +""" +from alembic import op +import sqlalchemy as sa + +revision = "0018" +down_revision = "0017" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column("note_attachments", sa.Column("sha256", sa.Text(), nullable=True)) + + +def downgrade() -> None: + op.drop_column("note_attachments", "sha256") diff --git a/frontend/src/stores/notes.ts b/frontend/src/stores/notes.ts index 509c0b8..825e3d5 100644 --- a/frontend/src/stores/notes.ts +++ b/frontend/src/stores/notes.ts @@ -27,6 +27,8 @@ export interface Attachment { id: string; url: string; mime: string; + size?: number; + sha256?: string | null; } // A past version of a note's title+body (version history). diff --git a/src/thoughtsync/models/note_attachment.py b/src/thoughtsync/models/note_attachment.py index 09e8a1f..340d2e3 100644 --- a/src/thoughtsync/models/note_attachment.py +++ b/src/thoughtsync/models/note_attachment.py @@ -23,4 +23,7 @@ class NoteAttachment(Base): path: Mapped[str] = mapped_column(Text(), nullable=False) # relative to media_root mime: Mapped[str] = mapped_column(Text(), nullable=False) size: Mapped[int] = mapped_column(BigInteger(), nullable=False) + # Content hash (sha256 hex) for client-side dedupe + integrity over sync. Nullable + # for rows created before this column (no backfill). + sha256: Mapped[str | None] = mapped_column(Text(), nullable=True) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) diff --git a/src/thoughtsync/notes.py b/src/thoughtsync/notes.py index efc9d68..ad6db25 100644 --- a/src/thoughtsync/notes.py +++ b/src/thoughtsync/notes.py @@ -1,5 +1,6 @@ from __future__ import annotations +import hashlib import io import json import os @@ -159,7 +160,13 @@ async def _attachments_for_notes(db, note_ids: list) -> dict: ).all() for att in rows: result.setdefault(att.note_id, []).append( - {"id": str(att.id), "url": _attachment_url(att.note_id, att.id), "mime": att.mime} + { + "id": str(att.id), + "url": _attachment_url(att.note_id, att.id), + "mime": att.mime, + "size": att.size, + "sha256": att.sha256, + } ) return result @@ -1187,12 +1194,36 @@ async def upload_attachment(note_id: str): ext = ALLOWED_IMAGE_MIMES.get(mime) if ext is None: return jsonify({"error": "unsupported image type (png, jpeg, gif, webp only)"}), 415 + # A native client may supply the attachment's id so an offline-attached file + # keeps its identity across sync. Re-uploading an id it already has is a no-op. + form = await request.form + raw_id = (form.get("id") or "").strip() att_id = uuid.uuid4() + if raw_id: + try: + att_id = uuid.UUID(raw_id) + except (ValueError, TypeError): + return jsonify({"error": "invalid attachment id"}), 400 + existing = await db.scalar( + select(NoteAttachment).where(NoteAttachment.id == att_id, NoteAttachment.note_id == note.id) + ) + if existing is not None: + return jsonify(await _serialize_note(db, note)) # already have this blob rel = os.path.join(str(note.id), f"{att_id}{ext}") dest = Config.media_root() / rel dest.parent.mkdir(parents=True, exist_ok=True) await upload.save(str(dest)) - db.add(NoteAttachment(id=att_id, note_id=note.id, path=rel, mime=mime, size=dest.stat().st_size)) + raw = dest.read_bytes() + db.add( + NoteAttachment( + id=att_id, + note_id=note.id, + path=rel, + mime=mime, + size=len(raw), + sha256=hashlib.sha256(raw).hexdigest(), + ) + ) await db.commit() return jsonify(await _serialize_note(db, note)), 201