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;
|
id: string;
|
||||||
url: string;
|
url: string;
|
||||||
mime: string;
|
mime: string;
|
||||||
|
size?: number;
|
||||||
|
sha256?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// A past version of a note's title+body (version history).
|
// 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
|
path: Mapped[str] = mapped_column(Text(), nullable=False) # relative to media_root
|
||||||
mime: Mapped[str] = mapped_column(Text(), nullable=False)
|
mime: Mapped[str] = mapped_column(Text(), nullable=False)
|
||||||
size: Mapped[int] = mapped_column(BigInteger(), 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())
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
import io
|
import io
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
@@ -159,7 +160,13 @@ async def _attachments_for_notes(db, note_ids: list) -> dict:
|
|||||||
).all()
|
).all()
|
||||||
for att in rows:
|
for att in rows:
|
||||||
result.setdefault(att.note_id, []).append(
|
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
|
return result
|
||||||
|
|
||||||
@@ -1187,12 +1194,36 @@ async def upload_attachment(note_id: str):
|
|||||||
ext = ALLOWED_IMAGE_MIMES.get(mime)
|
ext = ALLOWED_IMAGE_MIMES.get(mime)
|
||||||
if ext is None:
|
if ext is None:
|
||||||
return jsonify({"error": "unsupported image type (png, jpeg, gif, webp only)"}), 415
|
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()
|
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}")
|
rel = os.path.join(str(note.id), f"{att_id}{ext}")
|
||||||
dest = Config.media_root() / rel
|
dest = Config.media_root() / rel
|
||||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||||
await upload.save(str(dest))
|
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()
|
await db.commit()
|
||||||
return jsonify(await _serialize_note(db, note)), 201
|
return jsonify(await _serialize_note(db, note)), 201
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user