attachments sync: attach offline, upload when linked, removals stick (#5168)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Build now, or wait for Android? (push) Successful in 4s
Android / Build, or is the channel already serving this? (push) Successful in 4s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Web typecheck and unit tests (push) Successful in 9s
CI & Build / Python tests (push) Successful in 11s
CI & Build / integration (push) Successful in 35s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Failing after 2m23s
Desktop (Tauri) / Tauri desktop (Linux) (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Skipped
Desktop (Tauri) / Update manifest (push) Skipped
Android / Kotlin + Rust (APK) (push) Successful in 7m17s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Build now, or wait for Android? (push) Successful in 4s
Android / Build, or is the channel already serving this? (push) Successful in 4s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Web typecheck and unit tests (push) Successful in 9s
CI & Build / Python tests (push) Successful in 11s
CI & Build / integration (push) Successful in 35s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Failing after 2m23s
Desktop (Tauri) / Tauri desktop (Linux) (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Skipped
Desktop (Tauri) / Update manifest (push) Skipped
Android / Kotlin + Rust (APK) (push) Successful in 7m17s
Desktop could not create an attachment at all, and a removed attachment or dismissed preview came back on the next pull. Now: - core: add_attachment keeps the bytes in the blob store and queues the row (schema v10: attachments.uploaded / upload_error). Push uploads it once its note has landed. A refusal that retrying won't fix (too large, id clash, hash mismatch) is recorded on the file and not re-sent every cycle; the editor shows it. - core: removing a synced attachment or dismissing a preview leaves a tombstone in pending_deletes; push sends it as an `attachment`/`preview` delete, and a pull while it waits doesn't put the row back. A pull also keeps files still waiting to upload instead of replacing them wholesale. - server: PUT /api/sync/attachments/<id> (raw body, sha256-checked, idempotent, size-capped) and child deletes in push, which apply regardless of LWW and answer noop for rows the caller can't see. One store_attachment helper for the upload route, the importer and sync. Protocol 5, feature attachment_sync; the client sends neither to a server without it. - server: migration 0031 makes a link preview's insert/delete bump its note, so background-fetched previews and web dismissals reach linked devices. - desktop: Attach and paste-image work offline (raw-bytes IPC command). - SVG is served as a download by the desktop blob scheme too (as #1981 did for the web), and drawn as a file chip on both. - autosync: drop the catch_unwind; release builds abort on panic, so it only ever worked in debug builds. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+138
-1
@@ -15,18 +15,21 @@ deployment has ever seen.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from sqlalchemy import select, text
|
||||
from sqlalchemy import func, select, text
|
||||
|
||||
from inkwell import ratelimit
|
||||
from inkwell.app import create_app
|
||||
from inkwell.config import Config
|
||||
from inkwell.db import dispose_engine, session_scope
|
||||
from inkwell.models.label import NoteLabel
|
||||
from inkwell.models.note import Note
|
||||
from inkwell.models.note_attachment import NoteAttachment
|
||||
from inkwell.models.user import User
|
||||
from inkwell.notes.tags import _lift_and_reconcile_tags
|
||||
from inkwell.settings import get_setting, live, refresh_live, reset_live, set_settings
|
||||
@@ -737,3 +740,137 @@ async def test_a_pushed_edit_keeps_the_clients_edit_time_when_a_tag_is_lifted(ap
|
||||
)
|
||||
note = await (await app_client.get(f"/api/notes/{nid}")).get_json()
|
||||
assert datetime.fromisoformat(note["updated_at"]) == datetime(2026, 1, 1, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
# --- attachments as a sync entity (#5168) ---------------------------------------
|
||||
|
||||
|
||||
async def _note_revision(app_client, nid: str) -> int:
|
||||
feed = await (await app_client.get("/api/sync/changes?since=0")).get_json()
|
||||
return next(n["sync_revision"] for n in feed["notes"] if n["id"] == nid)
|
||||
|
||||
|
||||
async def _pushed_note(app_client, body: str = "with a file") -> str:
|
||||
nid = str(uuid.uuid4())
|
||||
resp = await app_client.post(
|
||||
"/api/sync/push",
|
||||
json={"changes": [{"entity": "note", "id": nid, "op": "upsert", "body": body,
|
||||
"edited_at": "2026-01-01T00:00:00Z", "created_at": "2026-01-01T00:00:00Z"}]},
|
||||
)
|
||||
assert (await resp.get_json())["results"][0]["status"] == "created"
|
||||
return nid
|
||||
|
||||
|
||||
async def _put(app_client, aid: str, nid: str, raw: bytes, sha: str | None = None, name: str = "scan.pdf"):
|
||||
return await app_client.put(
|
||||
f"/api/sync/attachments/{aid}",
|
||||
data=raw,
|
||||
headers={"Content-Type": "application/pdf"},
|
||||
query_string={"note_id": nid, "filename": name, "sha256": sha or hashlib.sha256(raw).hexdigest()},
|
||||
)
|
||||
|
||||
|
||||
async def test_an_offline_attachment_uploads_once_under_its_own_id(app_client, db):
|
||||
await _signed_in(app_client, "upload")
|
||||
nid = await _pushed_note(app_client)
|
||||
aid = str(uuid.uuid4())
|
||||
|
||||
assert (await _put(app_client, aid, nid, b"%PDF-1", sha="0" * 64)).status_code == 400, "hash mismatch refused"
|
||||
assert (await app_client.get(f"/api/notes/{nid}")).status_code == 200
|
||||
assert (await (await app_client.get(f"/api/notes/{nid}")).get_json())["attachments"] == []
|
||||
|
||||
before = await _note_revision(app_client, nid)
|
||||
first = await _put(app_client, aid, nid, b"%PDF-1")
|
||||
assert first.status_code == 201
|
||||
assert await _note_revision(app_client, nid) > before, "other devices hear about it"
|
||||
|
||||
again = await _put(app_client, aid, nid, b"%PDF-1")
|
||||
assert again.status_code == 200 and (await again.get_json())["status"] == "exists", "a retried upload is a no-op"
|
||||
|
||||
atts = (await (await app_client.get(f"/api/notes/{nid}")).get_json())["attachments"]
|
||||
assert [(a["id"], a["filename"], a["mime"], a["sha256"]) for a in atts] == [
|
||||
(aid, "scan.pdf", "application/pdf", hashlib.sha256(b"%PDF-1").hexdigest())
|
||||
]
|
||||
other = await _pushed_note(app_client, "another note")
|
||||
assert (await _put(app_client, aid, other, b"%PDF-1")).status_code == 409, "one id, one note"
|
||||
|
||||
|
||||
async def test_an_upload_to_a_note_the_caller_does_not_own_is_not_found(app_client, db):
|
||||
# Signed in first: registration is open only to the first account.
|
||||
await _signed_in(app_client, "intruder")
|
||||
stranger = User(email="stranger@example.test", display_name="Stranger")
|
||||
db.add(stranger)
|
||||
await db.flush()
|
||||
theirs = Note(owner_id=stranger.id, body="theirs", display_title="theirs")
|
||||
db.add(theirs)
|
||||
await db.commit()
|
||||
|
||||
resp = await _put(app_client, str(uuid.uuid4()), str(theirs.id), b"x")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
async def test_a_pushed_attachment_delete_removes_the_row_the_file_and_bumps_the_note(app_client, db):
|
||||
await _signed_in(app_client, "remove")
|
||||
nid = await _pushed_note(app_client)
|
||||
aid = str(uuid.uuid4())
|
||||
await _put(app_client, aid, nid, b"bytes")
|
||||
stored = (await db.scalar(select(NoteAttachment).where(NoteAttachment.id == uuid.UUID(aid)))).path
|
||||
assert (Config.media_root() / stored).is_file()
|
||||
|
||||
before = await _note_revision(app_client, nid)
|
||||
resp = await app_client.post(
|
||||
"/api/sync/push",
|
||||
json={"changes": [{"entity": "attachment", "id": aid, "op": "delete", "edited_at": "2000-01-01T00:00:00Z"}]},
|
||||
)
|
||||
assert (await resp.get_json())["results"] == [{"id": aid, "entity": "attachment", "status": "applied"}]
|
||||
assert (await (await app_client.get(f"/api/notes/{nid}")).get_json())["attachments"] == []
|
||||
assert not (Config.media_root() / stored).exists()
|
||||
# The edit time above is older than the note's: a removal is not a version
|
||||
# competing under last-write-wins, so it applies anyway.
|
||||
assert await _note_revision(app_client, nid) > before, "the note re-syncs without it"
|
||||
|
||||
|
||||
async def test_a_child_delete_cannot_reach_another_owners_rows(app_client, db):
|
||||
await _signed_in(app_client, "prober")
|
||||
stranger = User(email="other@example.test", display_name="Other")
|
||||
db.add(stranger)
|
||||
await db.flush()
|
||||
theirs = Note(owner_id=stranger.id, body="theirs", display_title="theirs")
|
||||
db.add(theirs)
|
||||
await db.flush()
|
||||
att = NoteAttachment(note_id=theirs.id, path="x/y.bin", mime="application/octet-stream", size=1)
|
||||
preview = NoteLinkPreview(note_id=theirs.id, url="https://example.com/")
|
||||
db.add_all([att, preview])
|
||||
await db.commit()
|
||||
|
||||
resp = await app_client.post(
|
||||
"/api/sync/push",
|
||||
json={"changes": [
|
||||
{"entity": "attachment", "id": str(att.id), "op": "delete", "edited_at": "2030-01-01T00:00:00Z"},
|
||||
{"entity": "preview", "id": str(preview.id), "op": "delete", "edited_at": "2030-01-01T00:00:00Z"},
|
||||
{"entity": "preview", "id": str(uuid.uuid4()), "op": "upsert", "edited_at": "2030-01-01T00:00:00Z"},
|
||||
]},
|
||||
)
|
||||
statuses = [r["status"] for r in (await resp.get_json())["results"]]
|
||||
# Someone else's row answers exactly like a missing one: nothing to learn here.
|
||||
assert statuses == ["noop", "noop", "rejected"]
|
||||
assert await db.scalar(select(func.count()).select_from(NoteAttachment)) == 1
|
||||
assert await db.scalar(select(func.count()).select_from(NoteLinkPreview)) == 1
|
||||
|
||||
|
||||
async def test_a_preview_arriving_or_leaving_moves_its_note_in_the_feed(app_client, db):
|
||||
"""0031: before it, a preview fetched after the save, or dismissed on the web,
|
||||
never reached a device that had already pulled the note."""
|
||||
await _signed_in(app_client, "previews")
|
||||
nid = await _pushed_note(app_client, "read https://example.com/a")
|
||||
|
||||
before = await _note_revision(app_client, nid)
|
||||
async with session_scope() as other: # as the background unfurl does
|
||||
other.add(NoteLinkPreview(note_id=uuid.UUID(nid), url="https://example.com/a", title="A"))
|
||||
await other.commit()
|
||||
arrived = await _note_revision(app_client, nid)
|
||||
assert arrived > before
|
||||
|
||||
preview_id = (await (await app_client.get(f"/api/notes/{nid}")).get_json())["previews"][0]["id"]
|
||||
await app_client.delete(f"/api/notes/{nid}/previews/{preview_id}")
|
||||
assert await _note_revision(app_client, nid) > arrived
|
||||
|
||||
@@ -34,6 +34,12 @@ async def test_push_requires_auth(app):
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
async def test_attachment_upload_requires_auth(app):
|
||||
client = app.test_client()
|
||||
resp = await client.put("/api/sync/attachments/00000000-0000-0000-0000-000000000000", data=b"x")
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
def test_client_wins():
|
||||
older = datetime(2026, 7, 20, tzinfo=timezone.utc)
|
||||
newer = datetime(2026, 7, 22, tzinfo=timezone.utc)
|
||||
|
||||
Reference in New Issue
Block a user