Sync 5: attachment blob sync — client id + content hash (M8)
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 11s
CI & Build / Build & push image (push) Successful in 44s

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
This commit is contained in:
2026-07-22 23:12:21 -04:00
co-authored by Claude Opus 4.8
parent 68abaa0f3f
commit 0ca244be3d
4 changed files with 63 additions and 2 deletions
@@ -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())
+33 -2
View File
@@ -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