sync: shared notes in the feed, revocations, and text pushes from an edit share
CI & Build / Build now, or wait for Android? (push) Successful in 2s
Android / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Python lint (push) Successful in 2s
Android / Kotlin + Rust (APK) (push) Skipped
CI & Build / Web typecheck and unit tests (push) Successful in 9s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Skipped
Desktop (Tauri) / Update manifest (push) Skipped
CI & Build / Python tests (push) Successful in 15s
CI & Build / integration (push) Successful in 1m4s
CI & Build / Build & push image (push) Successful in 54s
CI & Build / Build now, or wait for Android? (push) Successful in 2s
Android / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Python lint (push) Successful in 2s
Android / Kotlin + Rust (APK) (push) Skipped
CI & Build / Web typecheck and unit tests (push) Successful in 9s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Skipped
Desktop (Tauri) / Update manifest (push) Skipped
CI & Build / Python tests (push) Successful in 15s
CI & Build / integration (push) Successful in 1m4s
CI & Build / Build & push image (push) Successful in 54s
The change feed answers `?shares=1` with every note the caller can see, each saying how it is held, plus a `revoked` list of notes that left them. Granting a share moves the note past the recipient's cursor; ending one, or the owner deleting the note, leaves a revocation on the same cursor. A recipient at edit may push the note's text, and nothing else. Protocol 6, feature `shares`; opt-in, so the floor stays at 3. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,50 @@
|
||||
"""share_revocations: telling a recipient's devices a shared note has left them
|
||||
|
||||
Revision ID: 0035
|
||||
Revises: 0034
|
||||
Create Date: 2026-10-07
|
||||
|
||||
The change feed carries every note a person can see, including notes shared with
|
||||
them (#5175). When a share ends, the note stops being visible, so it simply stops
|
||||
appearing in the feed. A device that already holds a copy would keep it forever. A
|
||||
revocation is that missing signal: one row per (note, person) who lost the note,
|
||||
stamped from the same `sync_revision_seq` the notes and labels draw from, so it sits
|
||||
on the one cursor every client already pages by.
|
||||
|
||||
Sharing the note with that person again deletes the row, so a device that never
|
||||
heard of the revocation never gets told to delete a note it is meant to have.
|
||||
|
||||
## Downgrade
|
||||
|
||||
Drops the table. Devices that missed a revocation keep their copy.
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
|
||||
revision = "0035"
|
||||
down_revision = "0034"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"share_revocations",
|
||||
sa.Column("note_id", UUID(as_uuid=True), sa.ForeignKey("notes.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("user_id", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column(
|
||||
"sync_revision",
|
||||
sa.BigInteger(),
|
||||
nullable=False,
|
||||
server_default=sa.text("nextval('sync_revision_seq')"),
|
||||
),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||
sa.PrimaryKeyConstraint("note_id", "user_id"),
|
||||
)
|
||||
op.create_index("ix_share_revocations_user_revision", "share_revocations", ["user_id", "sync_revision"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_share_revocations_user_revision", table_name="share_revocations")
|
||||
op.drop_table("share_revocations")
|
||||
@@ -64,6 +64,9 @@ syncs everything else.
|
||||
- v5 (#5168): attachments became a sync entity — an upload route keyed by the
|
||||
client's id, and `attachment`/`preview` deletes in push. Additive, so it is
|
||||
the `attachment_sync` feature and the floor stays.
|
||||
- v6 (#5175): shared notes — see "Shared notes" below. Opt-in per request
|
||||
(`changes?shares=1`), so it is the `shares` feature and the floor stays: a v5
|
||||
client never asks and gets exactly its own notes.
|
||||
- **Additive change** (a new field, a new capability) → add a `sync_features`
|
||||
name. Do **not** raise a minimum. Old clients keep working.
|
||||
- **Breaking change only** → raise `MIN_CLIENT_PROTOCOL_VERSION` (or the client's
|
||||
@@ -196,6 +199,37 @@ boundaries, so nothing between `cursor` and the next pull is skipped. Loop while
|
||||
|
||||
Note attachment metadata carries `{id, url, mime, size, sha256}`.
|
||||
|
||||
### Shared notes (`shares`, v6)
|
||||
|
||||
With `?shares=1` the feed carries every note the caller can **see**: their own, plus
|
||||
those shared with them directly or through a group. Each note then also says how
|
||||
it is held:
|
||||
|
||||
```json
|
||||
{ "permission": "owner" | "edit" | "view",
|
||||
"shared": true,
|
||||
"shared_by": { "id": "...", "display_name": "..." } }
|
||||
```
|
||||
|
||||
`shared_by` is null on the caller's own notes, and `shared` says whether they have
|
||||
shared it with anyone. A note shared with the caller comes with `labels: []`:
|
||||
labels are personal, and a recipient files nothing under the owner's tags.
|
||||
|
||||
Granting or changing a share moves the note to a new revision (without touching
|
||||
`updated_at`, so last-write-wins is unaffected), which is how it passes a
|
||||
recipient's cursor. Ending a share — or the owner purging the note — adds the note
|
||||
to the recipient's `revoked` list:
|
||||
|
||||
```json
|
||||
{ "notes": [ ... ], "labels": [ ... ], "revoked": ["<note id>", ...],
|
||||
"cursor": 51, "has_more": false }
|
||||
```
|
||||
|
||||
A client deletes its copy of each revoked note. Revocations draw from the same
|
||||
sequence and page on the same cursor as notes and labels (three streams now; the
|
||||
smallest full boundary wins). Sharing with the person again deletes their
|
||||
revocation, so a device that never saw it is never told to drop a note it has.
|
||||
|
||||
## Push — `POST /api/sync/push`
|
||||
|
||||
Body: `{ "changes": [ ... ] }` (max 1000 per batch). Each change:
|
||||
@@ -233,6 +267,12 @@ Body: `{ "changes": [ ... ] }` (max 1000 per batch). Each change:
|
||||
the same as one that never existed. Removals are explicit rather than "the
|
||||
note's current attachment set" on purpose: push runs before pull, so a set
|
||||
would delete an attachment another device added that this one has not seen.
|
||||
- **A note someone else owns** (`shares`): a recipient at `edit` may push an upsert
|
||||
and only its `body` is applied, under the same last-write-wins — every other
|
||||
field is ignored, since pinning, archiving, reminders, labels and deleting stay
|
||||
the owner's. A `view` recipient's change, or any delete, is `rejected` ("only its
|
||||
owner can change that"); a note the caller can't see at all answers the generic
|
||||
"cannot apply".
|
||||
|
||||
### Conflict resolution — last-write-wins + history
|
||||
|
||||
|
||||
@@ -16,5 +16,6 @@ from . import ( # noqa: F401
|
||||
saved_filter,
|
||||
settings,
|
||||
share,
|
||||
share_revocation,
|
||||
user,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import BigInteger, DateTime, ForeignKey, func, text
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from . import Base
|
||||
|
||||
|
||||
class ShareRevocation(Base):
|
||||
"""A note that someone lost access to, for their devices to delete (#5175).
|
||||
|
||||
The change feed only carries notes a person can see, so a share ending would
|
||||
otherwise leave a stale copy on every device that had synced it. The revision
|
||||
comes from the shared `sync_revision_seq`, so a revocation pages on the same
|
||||
cursor as notes and labels. See `share_sync`.
|
||||
"""
|
||||
|
||||
__tablename__ = "share_revocations"
|
||||
|
||||
note_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("notes.id", ondelete="CASCADE"), primary_key=True
|
||||
)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), primary_key=True
|
||||
)
|
||||
sync_revision: Mapped[int] = mapped_column(
|
||||
BigInteger(), nullable=False, server_default=text("nextval('sync_revision_seq')")
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||
@@ -36,6 +36,7 @@ from .models.note_link_preview import NoteLinkPreview
|
||||
from .models.note_revision import NoteRevision
|
||||
from .models.share import Share
|
||||
from .settings import get_setting
|
||||
from .share_sync import recipients, revoke
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -88,7 +89,9 @@ async def purge_note(db, note: Note, edited_at: datetime | None = None) -> None:
|
||||
await db.execute(sa_delete(NoteLabel).where(NoteLabel.note_id == note.id))
|
||||
await db.execute(sa_delete(NoteLinkPreview).where(NoteLinkPreview.note_id == note.id))
|
||||
await db.execute(sa_delete(NoteRevision).where(NoteRevision.note_id == note.id))
|
||||
# Its shares too: a deleted note is shared with nobody.
|
||||
# Its shares too: a deleted note is shared with nobody. Each recipient's devices
|
||||
# are told first, since the tombstone below only reaches the owner's.
|
||||
await revoke(db, note.id, await recipients(db, note.id))
|
||||
await db.execute(sa_delete(Share).where(Share.resource_type == "note", Share.resource_id == note.id))
|
||||
note.body = ""
|
||||
note.display_title = ""
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
"""What sharing a note does to the change feed (#5175).
|
||||
|
||||
The feed carries every note a person can see, so a share changing has to show up in
|
||||
it: granting or changing a share moves the note past each device's cursor, and ending
|
||||
one leaves a revocation for the person who lost it (see `ShareRevocation`).
|
||||
|
||||
Kept apart from `shares_api` because `retention.purge_note` needs it too, and a
|
||||
purge must not import a blueprint to tell recipients a note is gone.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import delete as sa_delete
|
||||
from sqlalchemy import select, text
|
||||
from sqlalchemy.dialects.postgresql import insert
|
||||
|
||||
from .models.group import GroupMember
|
||||
from .models.share import Share
|
||||
from .models.share_revocation import ShareRevocation
|
||||
|
||||
|
||||
async def bump_note(db, note_id: uuid.UUID) -> None:
|
||||
"""Move the note past every device's cursor without changing it.
|
||||
|
||||
Raw SQL rather than `update(Note)`: the ORM would stamp `updated_at`, and that is
|
||||
the edit time last-write-wins compares, so a share would beat a real edit made
|
||||
offline a minute earlier. The row trigger draws the new `sync_revision`.
|
||||
"""
|
||||
await db.execute(text("UPDATE notes SET sync_revision = sync_revision WHERE id = :id"), {"id": note_id})
|
||||
|
||||
|
||||
async def recipients(db, note_id: uuid.UUID) -> set[uuid.UUID]:
|
||||
"""Everyone the note is shared with, directly or through a group."""
|
||||
direct = await db.scalars(
|
||||
select(Share.shared_with_user_id).where(
|
||||
Share.resource_type == "note", Share.resource_id == note_id, Share.shared_with_user_id.is_not(None)
|
||||
)
|
||||
)
|
||||
grouped = await db.scalars(
|
||||
select(GroupMember.user_id)
|
||||
.join(Share, Share.shared_with_group_id == GroupMember.group_id)
|
||||
.where(Share.resource_type == "note", Share.resource_id == note_id)
|
||||
)
|
||||
return set(direct.all()) | set(grouped.all())
|
||||
|
||||
|
||||
async def revoke(db, note_id: uuid.UUID, user_ids) -> None:
|
||||
"""Tell each person's devices the note has left them. A second revocation for the
|
||||
same person takes a fresh revision, so a device past the first still hears it."""
|
||||
for uid in user_ids:
|
||||
await db.execute(
|
||||
insert(ShareRevocation)
|
||||
.values(note_id=note_id, user_id=uid)
|
||||
.on_conflict_do_update(
|
||||
index_elements=[ShareRevocation.note_id, ShareRevocation.user_id],
|
||||
set_={"sync_revision": text("nextval('sync_revision_seq')")},
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def granted(db, note_id: uuid.UUID, user_id: uuid.UUID) -> None:
|
||||
"""A share to `user_id` exists again: forget any revocation, so a device that
|
||||
never synced it isn't told to delete a note it now has, and move the note on so
|
||||
every device hears about the grant."""
|
||||
await db.execute(
|
||||
sa_delete(ShareRevocation).where(ShareRevocation.note_id == note_id, ShareRevocation.user_id == user_id)
|
||||
)
|
||||
await bump_note(db, note_id)
|
||||
@@ -23,6 +23,7 @@ from .models.share import Share
|
||||
from .models.user import User
|
||||
from .notes.helpers import _get_owned
|
||||
from .responses import json_error, not_found, parse_uuid
|
||||
from .share_sync import bump_note, granted, recipients, revoke
|
||||
|
||||
bp = Blueprint("shares", __name__, url_prefix="/api")
|
||||
|
||||
@@ -105,6 +106,7 @@ async def share_note(note_id: str):
|
||||
set_={"permission": permission},
|
||||
)
|
||||
)
|
||||
await granted(db, note.id, target)
|
||||
await db.commit()
|
||||
return jsonify({"shares": await _shares_of(db, note.id)}), 201
|
||||
|
||||
@@ -122,6 +124,13 @@ async def unshare_note(note_id: str, share_id: str):
|
||||
)
|
||||
if share is None:
|
||||
return not_found()
|
||||
before = await recipients(db, note.id)
|
||||
await db.delete(share)
|
||||
await db.flush()
|
||||
# Only the people who can no longer see it: someone still reached through a
|
||||
# group keeps the note on their devices.
|
||||
await revoke(db, note.id, before - await recipients(db, note.id))
|
||||
# The owner's devices learn the note is no longer shared.
|
||||
await bump_note(db, note.id)
|
||||
await db.commit()
|
||||
return jsonify({"shares": await _shares_of(db, note.id)})
|
||||
|
||||
+95
-40
@@ -2,6 +2,8 @@
|
||||
|
||||
Pull: `GET /api/sync/changes?since=<cursor>` returns every note + label the caller
|
||||
owns whose sync_revision advanced past the cursor, newest-revision last, paginated.
|
||||
With `shares=1` it also carries the notes shared with the caller, and `revoked`: the
|
||||
notes that stopped being shared with them (#5175).
|
||||
Notes and labels both draw from ONE shared sequence (sync_revision_seq), so the
|
||||
cursor is a single monotonic watermark across both entity types.
|
||||
|
||||
@@ -19,6 +21,7 @@ from quart import Blueprint, g, jsonify, request
|
||||
from sqlalchemy import delete as sa_delete
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from .acl import visible_to_user
|
||||
from .auth import login_required
|
||||
from .common import iso, parse_dt
|
||||
from .db import session_scope
|
||||
@@ -27,13 +30,14 @@ from .models.label import Label, NoteLabel
|
||||
from .models.note import Note
|
||||
from .models.note_attachment import NoteAttachment
|
||||
from .models.note_link_preview import NoteLinkPreview
|
||||
from .models.share_revocation import ShareRevocation
|
||||
from .notes import (
|
||||
_serialize_notes,
|
||||
normalize_color,
|
||||
normalize_recurrence,
|
||||
)
|
||||
from .notes.body import write_body
|
||||
from .notes.helpers import store_attachment, unlink_media
|
||||
from .notes.helpers import _get_editable, store_attachment, unlink_media
|
||||
from .responses import json_error, not_found, parse_uuid
|
||||
from .settings import get_setting
|
||||
from .retention import purge_note
|
||||
@@ -77,7 +81,12 @@ MAX_PUSH = 1000 # per-batch change cap
|
||||
# v5 (#5168): attachments became a sync entity — `PUT /attachments/<id>` uploads a file
|
||||
# attached offline, and push takes `attachment` and `preview` deletes. Additive, so the
|
||||
# floor stays: a v4 client never sends either, and gets the same notes it always did.
|
||||
SYNC_PROTOCOL_VERSION = 5
|
||||
# v6 (#5175): shared notes. A client asking `changes?shares=1` gets the notes shared
|
||||
# with it, each saying how it is held (`permission`, `shared`, `shared_by`), and a
|
||||
# `revoked` list of notes that have left it; push takes body edits to notes shared at
|
||||
# `edit`. Additive and opt-in, so the floor stays: a v5 client never asks, and gets
|
||||
# its own notes exactly as before.
|
||||
SYNC_PROTOCOL_VERSION = 6
|
||||
MIN_CLIENT_PROTOCOL_VERSION = 3
|
||||
|
||||
# Named capabilities beyond the base protocol. An ADDITIVE change earns a name
|
||||
@@ -92,6 +101,7 @@ SYNC_FEATURES: tuple[str, ...] = (
|
||||
"tombstones", # purge propagates as a content-less row
|
||||
"revisions", # an overwritten version snapshots into note history
|
||||
"attachment_sync", # upload by client id + attachment/preview deletes in push (v5)
|
||||
"shares", # notes shared with the caller in the feed, `revoked`, edit-share pushes (v6)
|
||||
)
|
||||
|
||||
|
||||
@@ -123,24 +133,22 @@ def _clamp_limit(raw: str | None) -> int:
|
||||
return DEFAULT_LIMIT
|
||||
|
||||
|
||||
def _page_cursor(note_revs: list[int], label_revs: list[int], since: int, limit: int) -> tuple[int, bool]:
|
||||
"""Compute the next cursor + has_more when paging TWO revision streams that share
|
||||
one sequence. Each stream is fetched `rev > since ORDER BY rev LIMIT limit`.
|
||||
def _page_cursor(streams: list[list[int]], since: int, limit: int) -> tuple[int, bool]:
|
||||
"""Compute the next cursor + has_more when paging several revision streams that
|
||||
share one sequence (notes, labels, and revocations). Each stream is fetched
|
||||
`rev > since ORDER BY rev LIMIT limit`.
|
||||
|
||||
If either stream came back FULL (== limit) we're truncating, so the safe cursor is
|
||||
the SMALLER of the two page boundaries — advancing only to where BOTH streams are
|
||||
fully drained, so nothing between the cursor and the next pull is skipped. If
|
||||
neither is full, everything ≤ max(returned) is drained. Both lists are ascending.
|
||||
If any stream came back FULL (== limit) we're truncating, so the safe cursor is
|
||||
the SMALLEST of the full streams' page boundaries — advancing only to where EVERY
|
||||
stream is fully drained, so nothing between the cursor and the next pull is
|
||||
skipped. If none is full, everything ≤ max(returned) is drained. Each list is
|
||||
ascending.
|
||||
"""
|
||||
boundaries = []
|
||||
if len(note_revs) == limit:
|
||||
boundaries.append(note_revs[-1])
|
||||
if len(label_revs) == limit:
|
||||
boundaries.append(label_revs[-1])
|
||||
boundaries = [revs[-1] for revs in streams if len(revs) == limit]
|
||||
if boundaries:
|
||||
return min(boundaries), True
|
||||
all_revs = note_revs + label_revs
|
||||
return (max(all_revs) if all_revs else since), False
|
||||
every = [rev for revs in streams for rev in revs]
|
||||
return (max(every) if every else since), False
|
||||
|
||||
|
||||
@bp.get("/changes")
|
||||
@@ -148,15 +156,20 @@ def _page_cursor(note_revs: list[int], label_revs: list[int], since: int, limit:
|
||||
async def changes():
|
||||
since = _parse_since(request.args.get("since"))
|
||||
limit = _clamp_limit(request.args.get("limit"))
|
||||
# `shares=1` is a client that understands shared notes (protocol 6, `shares`).
|
||||
# Anything older gets only its own notes, as before, rather than someone else's
|
||||
# notes it would treat as its own: pinning them, labelling them, pushing them.
|
||||
with_shares = request.args.get("shares") == "1"
|
||||
if with_shares:
|
||||
visible = visible_to_user("note", Note.owner_id, Note.id, g.user_id)
|
||||
else:
|
||||
visible = Note.owner_id == g.user_id
|
||||
async with session_scope() as db:
|
||||
# ALL of the owner's notes/labels (any state — active/archived/trash/purged),
|
||||
# Every note the caller can see, in any state (active/archived/trash/purged),
|
||||
# since a client mirrors everything; ordered by the shared revision.
|
||||
note_rows = (
|
||||
await db.scalars(
|
||||
select(Note)
|
||||
.where(Note.owner_id == g.user_id, Note.sync_revision > since)
|
||||
.order_by(Note.sync_revision)
|
||||
.limit(limit)
|
||||
select(Note).where(visible, Note.sync_revision > since).order_by(Note.sync_revision).limit(limit)
|
||||
)
|
||||
).all()
|
||||
label_rows = (
|
||||
@@ -167,33 +180,50 @@ async def changes():
|
||||
.limit(limit)
|
||||
)
|
||||
).all()
|
||||
revoked_rows = (
|
||||
(
|
||||
await db.execute(
|
||||
select(ShareRevocation.note_id, ShareRevocation.sync_revision)
|
||||
.where(ShareRevocation.user_id == g.user_id, ShareRevocation.sync_revision > since)
|
||||
.order_by(ShareRevocation.sync_revision)
|
||||
.limit(limit)
|
||||
)
|
||||
).all()
|
||||
if with_shares
|
||||
else []
|
||||
)
|
||||
|
||||
cursor, has_more = _page_cursor(
|
||||
[n.sync_revision for n in note_rows],
|
||||
[lb.sync_revision for lb in label_rows],
|
||||
[
|
||||
[n.sync_revision for n in note_rows],
|
||||
[lb.sync_revision for lb in label_rows],
|
||||
[rev for _, rev in revoked_rows],
|
||||
],
|
||||
since,
|
||||
limit,
|
||||
)
|
||||
# Trim each stream to the shared watermark so the two feeds stay aligned.
|
||||
# Trim each stream to the shared watermark so the feeds stay aligned.
|
||||
note_rows = [n for n in note_rows if n.sync_revision <= cursor]
|
||||
label_rows = [lb for lb in label_rows if lb.sync_revision <= cursor]
|
||||
revoked = [str(nid) for nid, rev in revoked_rows if rev <= cursor]
|
||||
|
||||
# Note bodies come from the shared note serializer; sync adds the two
|
||||
# delta-only fields on top (folding these into the serializer itself waits on
|
||||
# the notes.py serialization split).
|
||||
notes_out = await _serialize_notes(db, note_rows)
|
||||
# delta-only fields on top. With shares, each note also says how the caller
|
||||
# holds it, and a note shared with them comes without the owner's labels.
|
||||
notes_out = await _serialize_notes(db, note_rows, g.user_id if with_shares else None)
|
||||
for data, n in zip(notes_out, note_rows):
|
||||
data["sync_revision"] = n.sync_revision
|
||||
data["purged_at"] = iso(n.purged_at)
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"notes": notes_out,
|
||||
"labels": [serialize_label_sync(lb) for lb in label_rows],
|
||||
"cursor": cursor,
|
||||
"has_more": has_more,
|
||||
}
|
||||
)
|
||||
page = {
|
||||
"notes": notes_out,
|
||||
"labels": [serialize_label_sync(lb) for lb in label_rows],
|
||||
"cursor": cursor,
|
||||
"has_more": has_more,
|
||||
}
|
||||
if with_shares:
|
||||
page["revoked"] = revoked
|
||||
return jsonify(page)
|
||||
|
||||
|
||||
# --- Push: apply client mutations (LWW by client edit-time, non-destructive) ---
|
||||
@@ -264,11 +294,7 @@ async def _apply_note(db, ch: dict, previews: list[tuple[uuid.UUID, str]]) -> di
|
||||
|
||||
note = await db.scalar(select(Note).where(Note.id == nid))
|
||||
if note is not None and note.owner_id != g.user_id:
|
||||
# A client only ever pushes ids of notes IT created, so this branch is only
|
||||
# reached by a probe (or a ~0-probability UUID collision). Reject with a
|
||||
# GENERIC message so the response doesn't confirm the id belongs to another
|
||||
# user (don't leak existence via a distinctive "not yours").
|
||||
return {"id": str(nid), "entity": "note", "status": "rejected", "error": "cannot apply"}
|
||||
return await _apply_shared_note(db, note, ch, edited_at, previews)
|
||||
|
||||
if op == "delete":
|
||||
if note is None:
|
||||
@@ -319,6 +345,35 @@ async def _apply_note(db, ch: dict, previews: list[tuple[uuid.UUID, str]]) -> di
|
||||
}
|
||||
|
||||
|
||||
async def _apply_shared_note(db, note: Note, ch: dict, edited_at, previews: list[tuple[uuid.UUID, str]]) -> dict:
|
||||
"""Apply a pushed change to a note someone else owns (#5175).
|
||||
|
||||
Someone it is shared with at `edit` may change its text, exactly as in the web
|
||||
app: the body goes through `write_body` under the same last-write-wins, and every
|
||||
other field in the change is ignored, since pin, archive, reminders, labels and
|
||||
deletion are the owner's. Anything else is rejected. A note the caller cannot see
|
||||
at all gets the same generic answer as an id that doesn't exist, so the reply
|
||||
can't be used to learn that someone else's id is real.
|
||||
"""
|
||||
nid = str(note.id)
|
||||
if ch.get("op", "upsert") == "upsert" and await _get_editable(db, nid) is not None:
|
||||
if not client_wins(edited_at, note.updated_at):
|
||||
return {"id": nid, "entity": "note", "status": "kept", "sync_revision": note.sync_revision}
|
||||
body = ch["body"] if isinstance(ch.get("body"), str) else note.body
|
||||
if await write_body(db, note, body):
|
||||
previews.append((note.id, note.body))
|
||||
if edited_at is not None:
|
||||
note.updated_at = edited_at
|
||||
await db.flush()
|
||||
await db.refresh(note, ["sync_revision"])
|
||||
return {"id": nid, "entity": "note", "status": "applied", "sync_revision": note.sync_revision}
|
||||
visible = await db.scalar(
|
||||
select(Note.id).where(Note.id == note.id, visible_to_user("note", Note.owner_id, Note.id, g.user_id))
|
||||
)
|
||||
error = "only its owner can change that" if visible is not None else "cannot apply"
|
||||
return {"id": nid, "entity": "note", "status": "rejected", "error": error}
|
||||
|
||||
|
||||
async def _apply_label(db, ch: dict) -> dict:
|
||||
raw_id = ch.get("id")
|
||||
try:
|
||||
|
||||
@@ -51,7 +51,7 @@ pytestmark = pytest.mark.integration
|
||||
|
||||
# Every table the tests touch, child-first so FKs never block the truncate.
|
||||
# RESTART IDENTITY + CASCADE keeps this honest if a table gains children later.
|
||||
_TABLES = "notes, note_revisions, note_labels, note_link_previews, labels, shares, invites, password_resets, users"
|
||||
_TABLES = "notes, note_revisions, note_labels, note_link_previews, labels, shares, share_revocations, invites, password_resets, users"
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
@@ -1343,6 +1343,100 @@ async def test_a_share_names_someone_else_on_this_instance(app_client, db):
|
||||
assert (await app_client.delete(f"/api/notes/{other}/shares/{sid}")).status_code == 404
|
||||
|
||||
|
||||
# --- Shared notes over sync (#5175) -------------------------------------------
|
||||
|
||||
|
||||
async def _feed(client, since: int = 0, shares: bool = True) -> dict:
|
||||
query = f"since={since}" + ("&shares=1" if shares else "")
|
||||
resp = await client.get(f"/api/sync/changes?{query}")
|
||||
assert resp.status_code == 200
|
||||
return await resp.get_json()
|
||||
|
||||
|
||||
def _feed_note(page: dict, nid: str) -> dict | None:
|
||||
return next((n for n in page["notes"] if n["id"] == nid), None)
|
||||
|
||||
|
||||
async def test_a_share_reaches_the_recipients_feed_and_a_revoke_takes_it_back(app_client, db):
|
||||
recipient, stranger, people = await _three_people(app_client)
|
||||
nid = await _owners_note(app_client)
|
||||
start = (await _feed(recipient))["cursor"]
|
||||
|
||||
share_id = (await (await _share(app_client, nid, people["recipient"])).get_json())["shares"][0]["id"]
|
||||
granted = await _feed(recipient, start)
|
||||
held = _feed_note(granted, nid)
|
||||
assert held is not None, "the grant moved the note past the recipient's cursor"
|
||||
assert (held["permission"], held["shared_by"]["display_name"], held["labels"]) == ("view", "owner", [])
|
||||
assert granted["revoked"] == []
|
||||
# A client that never asked for shares gets only its own notes, as before.
|
||||
assert _feed_note(await _feed(recipient, shares=False), nid) is None
|
||||
assert "revoked" not in await _feed(recipient, shares=False)
|
||||
assert _feed_note(await _feed(stranger), nid) is None
|
||||
|
||||
# The owner's devices learn the note is shared.
|
||||
mine = _feed_note(await _feed(app_client), nid)
|
||||
assert (mine["permission"], mine["shared"], [lb["name"] for lb in mine["labels"]]) == ("owner", True, ["idea"])
|
||||
|
||||
await app_client.delete(f"/api/notes/{nid}/shares/{share_id}")
|
||||
revoked = await _feed(recipient, granted["cursor"])
|
||||
assert revoked["revoked"] == [nid]
|
||||
assert _feed_note(revoked, nid) is None
|
||||
assert _feed_note(await _feed(app_client), nid)["shared"] is False
|
||||
assert (await _feed(stranger))["revoked"] == []
|
||||
|
||||
# Shared again: a device that never heard of the revocation isn't told to delete it.
|
||||
await _share(app_client, nid, people["recipient"], "edit")
|
||||
fresh = await _feed(recipient, start)
|
||||
assert fresh["revoked"] == []
|
||||
assert _feed_note(fresh, nid)["permission"] == "edit"
|
||||
|
||||
|
||||
async def test_an_edit_share_pushes_text_and_nothing_else(app_client, db):
|
||||
recipient, stranger, people = await _three_people(app_client)
|
||||
nid = await _owners_note(app_client, "first line")
|
||||
await _share(app_client, nid, people["recipient"], "view")
|
||||
|
||||
def change(**fields) -> dict:
|
||||
return {"changes": [{"entity": "note", "id": nid, "op": "upsert", "edited_at": "2099-01-01T00:00:00Z", **fields}]}
|
||||
|
||||
async def push(client, payload: dict) -> dict:
|
||||
return (await (await client.post("/api/sync/push", json=payload)).get_json())["results"][0]
|
||||
|
||||
viewed = await push(recipient, change(body="mine now"))
|
||||
assert (viewed["status"], viewed["error"]) == ("rejected", "only its owner can change that")
|
||||
probed = await push(stranger, change(body="mine now"))
|
||||
assert (probed["status"], probed["error"]) == ("rejected", "cannot apply")
|
||||
|
||||
await _share(app_client, nid, people["recipient"], "edit")
|
||||
edited = await push(recipient, change(body="first line, from the phone", pinned=True, archived=True, label_ids=[]))
|
||||
assert edited["status"] == "applied", edited
|
||||
note = await (await app_client.get(f"/api/notes/{nid}")).get_json()
|
||||
assert note["body"] == "first line, from the phone"
|
||||
# Everything but the text stays the owner's.
|
||||
assert (note["pinned"], note["archived"]) == (False, False)
|
||||
|
||||
deleted = await push(recipient, {"changes": [{"entity": "note", "id": nid, "op": "delete", "edited_at": "2099-01-02T00:00:00Z"}]})
|
||||
assert deleted["status"] == "rejected"
|
||||
assert (await app_client.get(f"/api/notes/{nid}")).status_code == 200
|
||||
|
||||
|
||||
async def test_deleting_a_shared_note_reaches_the_recipients_devices(app_client, db):
|
||||
recipient, _, people = await _three_people(app_client)
|
||||
nid = await _owners_note(app_client)
|
||||
await _share(app_client, nid, people["recipient"])
|
||||
seen = await _feed(recipient)
|
||||
assert _feed_note(seen, nid) is not None
|
||||
|
||||
assert (await app_client.post(f"/api/notes/{nid}/trash")).status_code == 200
|
||||
trashed = _feed_note(await _feed(recipient, seen["cursor"]), nid)
|
||||
assert trashed is not None and trashed["trashed"] is True
|
||||
|
||||
assert (await app_client.delete(f"/api/notes/{nid}")).status_code == 200
|
||||
gone = await _feed(recipient, seen["cursor"])
|
||||
assert gone["revoked"] == [nid]
|
||||
assert _feed_note(gone, nid) is None
|
||||
|
||||
|
||||
# --- Password reset by email (#5266) ------------------------------------------
|
||||
#
|
||||
# The SMTP hand-off is replaced by a list; everything up to it is real.
|
||||
|
||||
+11
-4
@@ -68,32 +68,39 @@ def test_clamp_limit():
|
||||
|
||||
def test_page_cursor_all_drained():
|
||||
# Neither stream is full → cursor is the max revision seen; nothing more to page.
|
||||
cursor, more = _page_cursor([1, 3, 5], [2, 4], since=0, limit=500)
|
||||
cursor, more = _page_cursor([[1, 3, 5], [2, 4], []], since=0, limit=500)
|
||||
assert cursor == 5
|
||||
assert more is False
|
||||
|
||||
|
||||
def test_page_cursor_empty():
|
||||
# No changes since the cursor → cursor stays put, no more pages.
|
||||
cursor, more = _page_cursor([], [], since=7, limit=500)
|
||||
cursor, more = _page_cursor([[], [], []], since=7, limit=500)
|
||||
assert cursor == 7
|
||||
assert more is False
|
||||
|
||||
|
||||
def test_page_cursor_one_stream_full_advances_to_its_boundary():
|
||||
# Notes came back full (limit=3) → truncate at its boundary; later labels defer.
|
||||
cursor, more = _page_cursor([1, 2, 3], [4, 5], since=0, limit=3)
|
||||
cursor, more = _page_cursor([[1, 2, 3], [4, 5], []], since=0, limit=3)
|
||||
assert cursor == 3
|
||||
assert more is True
|
||||
|
||||
|
||||
def test_page_cursor_both_full_uses_min_boundary():
|
||||
# Both full → advance only to the SMALLER boundary so neither stream skips a gap.
|
||||
cursor, more = _page_cursor([1, 2, 10], [3, 4, 5], since=0, limit=3)
|
||||
cursor, more = _page_cursor([[1, 2, 10], [3, 4, 5], []], since=0, limit=3)
|
||||
assert cursor == 5
|
||||
assert more is True
|
||||
|
||||
|
||||
def test_page_cursor_a_full_revocation_stream_holds_the_cursor_back():
|
||||
# Revocations page on the same cursor: a full one truncates like any other stream.
|
||||
cursor, more = _page_cursor([[1, 9], [], [2, 3, 4]], since=0, limit=3)
|
||||
assert cursor == 4
|
||||
assert more is True
|
||||
|
||||
|
||||
# --- protocol handshake (M10.6) ---------------------------------------------
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user