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:
@@ -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