Opening a purged note, or one of its files, is a 404

get_note and get_attachment selected with the ACL inline and skipped the purged
filter, so a tombstone came back 200 (#2128 says a purged note reads as absent).
Both now go through _get_visible, which cannot skip it. Reorder's batch lookup
gains the same filter, so a stale id cannot write a place onto a tombstone.

Fixes #5383.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 14:09:40 -04:00
co-authored by Claude Opus 5.5
parent 8eff5f60a6
commit bfab8746ad
2 changed files with 26 additions and 14 deletions
+10 -14
View File
@@ -387,7 +387,9 @@ async def reorder_notes():
for n in ( for n in (
await db.scalars( await db.scalars(
select(Note).where( select(Note).where(
Note.id.in_(parsed), visible_to_user("note", Note.owner_id, Note.id, g.user_id) Note.id.in_(parsed),
visible_to_user("note", Note.owner_id, Note.id, g.user_id),
Note.purged_at.is_(None),
) )
) )
).all() ).all()
@@ -431,13 +433,8 @@ async def create_note():
@bp.get("/<note_id>") @bp.get("/<note_id>")
@login_required @login_required
async def get_note(note_id: str): async def get_note(note_id: str):
nid = parse_uuid(note_id)
if nid is None:
return not_found()
async with session_scope() as db: async with session_scope() as db:
note = await db.scalar( note = await _get_visible(db, note_id)
select(Note).where(Note.id == nid, visible_to_user("note", Note.owner_id, Note.id, g.user_id))
)
if note is None: if note is None:
return not_found() return not_found()
return jsonify(await _serialize_note(db, note, g.user_id)) return jsonify(await _serialize_note(db, note, g.user_id))
@@ -653,18 +650,17 @@ async def upload_attachment(note_id: str):
@bp.get("/<note_id>/attachments/<att_id>") @bp.get("/<note_id>/attachments/<att_id>")
@login_required @login_required
async def get_attachment(note_id: str, att_id: str): async def get_attachment(note_id: str, att_id: str):
nid = parse_uuid(note_id)
aid = parse_uuid(att_id) aid = parse_uuid(att_id)
if nid is None or aid is None: if aid is None:
return not_found() return not_found()
async with session_scope() as db: async with session_scope() as db:
# Owner OR shared may view (rule 47). # Owner OR shared may view (rule 47). A purged note's files are gone with it.
note = await db.scalar( note = await _get_visible(db, note_id)
select(Note).where(Note.id == nid, visible_to_user("note", Note.owner_id, Note.id, g.user_id))
)
if note is None: if note is None:
return not_found() return not_found()
att = await db.scalar(select(NoteAttachment).where(NoteAttachment.id == aid, NoteAttachment.note_id == nid)) att = await db.scalar(
select(NoteAttachment).where(NoteAttachment.id == aid, NoteAttachment.note_id == note.id)
)
if att is None: if att is None:
return not_found() return not_found()
mime = att.mime mime = att.mime
+16
View File
@@ -891,6 +891,22 @@ async def test_an_offline_attachment_uploads_once_under_its_own_id(app_client, d
assert (await _put(app_client, aid, other, b"%PDF-1")).status_code == 409, "one id, one note" assert (await _put(app_client, aid, other, b"%PDF-1")).status_code == 409, "one id, one note"
async def test_a_purged_note_and_its_files_read_as_gone(app_client, db):
"""A purged note reads as absent (#2128). Opening it and its attachment selected
with the ACL inline and skipped that filter, so a tombstone came back 200 (#5383)."""
await _signed_in(app_client, "purged")
nid = await _pushed_note(app_client)
aid = str(uuid.uuid4())
assert (await _put(app_client, aid, nid, b"%PDF-1")).status_code == 201
assert (await app_client.get(f"/api/notes/{nid}/attachments/{aid}")).status_code == 200
assert (await app_client.post(f"/api/notes/{nid}/trash")).status_code == 200
assert (await app_client.delete(f"/api/notes/{nid}")).status_code == 200
assert (await app_client.get(f"/api/notes/{nid}")).status_code == 404
assert (await app_client.get(f"/api/notes/{nid}/attachments/{aid}")).status_code == 404
async def test_an_upload_to_a_note_the_caller_does_not_own_is_not_found(app_client, db): 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. # Signed in first: registration is open only to the first account.
await _signed_in(app_client, "intruder") await _signed_in(app_client, "intruder")