Sync 5: attachment blob sync — client id + content hash (M8)
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:
@@ -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")
|
||||
@@ -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).
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user