Skip to content

Commit e1c821a

Browse files
authored
Merge pull request #7683 from freedomofpress/client-writes2
Add remaining write methods to APIv2
2 parents 28423c2 + e4460d8 commit e1c821a

File tree

5 files changed

+491
-38
lines changed

5 files changed

+491
-38
lines changed

securedrop/journalist_app/api2/__init__.py

Lines changed: 3 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,14 @@
1-
import hashlib
21
from dataclasses import asdict
3-
from typing import Mapping, Optional
2+
from typing import Optional
43

5-
from flask import Blueprint, abort, json, jsonify, request
4+
from flask import Blueprint, abort, jsonify, request
65
from journalist_app.api2.events import EventHandler
6+
from journalist_app.api2.shared import json_version
77
from journalist_app.api2.types import (
88
BatchRequest,
99
BatchResponse,
1010
Event,
1111
Index,
12-
Version,
1312
)
1413
from journalist_app.sessions import session
1514
from models import EagerQuery, Journalist, Reply, Source, Submission, eager_query
@@ -25,20 +24,6 @@
2524
PREFIX_MAX_LEN = inspect(Source).columns["uuid"].type.length
2625

2726

28-
def json_version(d: Mapping) -> Version:
29-
"""
30-
Calculate the version (BLAKE2s digest) of the normalized JSON representation
31-
of the dictionary ``d``.
32-
33-
We use BLAKE2s here because SHA-256 is too slow (we don't care about
34-
cryptographic security) and CRC-32 is too collision-prone (we're not merely
35-
checksumming for transmission integrity).
36-
"""
37-
s = json.dumps(d, separators=[",", ":"], sort_keys=True)
38-
b = s.encode("utf-8")
39-
return Version(hashlib.blake2s(b).hexdigest())
40-
41-
4227
@blp.get("/index")
4328
@blp.get("/index/<string:source_prefix>")
4429
def index(source_prefix: Optional[str] = None) -> Response:

securedrop/journalist_app/api2/events.py

Lines changed: 163 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
from db import db
22
from journalist_app import utils
3-
from journalist_app.api2.shared import save_reply
3+
from journalist_app.api2.shared import json_version, save_reply
44
from journalist_app.api2.types import (
55
Event,
66
EventResult,
77
EventStatusCode,
88
EventType,
9+
ItemUUID,
910
)
10-
from journalist_app.sessions import Session
11+
from journalist_app.sessions import Session, session
1112
from models import Reply, Source, Submission
1213
from redis import Redis
1314
from sqlalchemy.orm.exc import MultipleResultsFound, NoResultFound
@@ -53,7 +54,12 @@ def process(self, event: Event) -> EventResult:
5354

5455
handler = {
5556
EventType.ITEM_DELETED: self.handle_item_deleted,
57+
EventType.ITEM_SEEN: self.handle_item_seen,
5658
EventType.REPLY_SENT: self.handle_reply_sent,
59+
EventType.SOURCE_DELETED: self.handle_source_deleted,
60+
EventType.SOURCE_CONVERSATION_DELETED: self.handle_source_conversation_deleted,
61+
EventType.SOURCE_STARRED: self.handle_source_starred,
62+
EventType.SOURCE_UNSTARRED: self.handle_source_unstarred,
5763
}[event.type]
5864
except KeyError:
5965
return EventResult(
@@ -86,24 +92,11 @@ def mark_progress(
8692

8793
@staticmethod
8894
def handle_item_deleted(event: Event) -> EventResult:
89-
submission = Submission.query.filter(
90-
Submission.uuid == event.target.item_uuid
91-
).one_or_none()
92-
reply = Reply.query.filter(Reply.uuid == event.target.item_uuid).one_or_none()
93-
94-
if submission and reply:
95-
# Fail if we get unlucky and hit a UUID collision between the
96-
# `Submission` and `Reply` tables. This is vanishingly unlikely,
97-
# but SQLite can't enforce uniqueness between them.
98-
raise MultipleResultsFound(
99-
f"found {event.target.item_uuid} in both submissions and replies"
100-
)
101-
102-
item = submission or reply
95+
item = find_item(event.target.item_uuid)
10396
if item is None:
10497
return EventResult(
10598
event_id=event.id,
106-
status=(EventStatusCode.NotFound, f"could not find item: {event.target.item_uuid}"),
99+
status=(EventStatusCode.Gone, None),
107100
)
108101

109102
utils.delete_file_object(item)
@@ -135,3 +128,156 @@ def handle_reply_sent(event: Event) -> EventResult:
135128
sources={source.uuid: source},
136129
items={reply.uuid: reply},
137130
)
131+
132+
@staticmethod
133+
def handle_source_deleted(event: Event) -> EventResult:
134+
try:
135+
source = Source.query.filter(Source.uuid == event.target.source_uuid).one()
136+
except NoResultFound:
137+
return EventResult(
138+
event_id=event.id,
139+
status=(
140+
EventStatusCode.Gone,
141+
None,
142+
),
143+
)
144+
145+
current_version = json_version(source.to_api_v2())
146+
if event.target.version != current_version:
147+
return EventResult(
148+
event_id=event.id,
149+
status=(
150+
EventStatusCode.Conflict,
151+
f"outdated source: expected {current_version}, got {event.target.version}",
152+
),
153+
)
154+
155+
# Mark as deleted all the items in the source's collection
156+
deleted_items = {item.uuid: None for item in source.collection}
157+
158+
utils.delete_collection(source.filesystem_id)
159+
return EventResult(
160+
event_id=event.id,
161+
status=(EventStatusCode.OK, None),
162+
sources={event.target.source_uuid: None},
163+
items=deleted_items,
164+
)
165+
166+
@staticmethod
167+
def handle_source_conversation_deleted(event: Event) -> EventResult:
168+
try:
169+
source = Source.query.filter(Source.uuid == event.target.source_uuid).one()
170+
except NoResultFound:
171+
return EventResult(
172+
event_id=event.id,
173+
status=(
174+
EventStatusCode.Gone,
175+
None,
176+
),
177+
)
178+
179+
current_version = json_version(source.to_api_v2())
180+
if event.target.version != current_version:
181+
return EventResult(
182+
event_id=event.id,
183+
status=(
184+
EventStatusCode.Conflict,
185+
f"outdated source: expected {current_version}, got {event.target.version}",
186+
),
187+
)
188+
189+
# Mark as deleted all the items in the source's collection
190+
deleted_items = {item.uuid: None for item in source.collection}
191+
192+
utils.delete_source_files(source.filesystem_id)
193+
db.session.refresh(source)
194+
195+
return EventResult(
196+
event_id=event.id,
197+
status=(EventStatusCode.OK, None),
198+
sources={source.uuid: source},
199+
items=deleted_items,
200+
)
201+
202+
@staticmethod
203+
def handle_source_starred(event: Event) -> EventResult:
204+
try:
205+
source = Source.query.filter(Source.uuid == event.target.source_uuid).one()
206+
except NoResultFound:
207+
return EventResult(
208+
event_id=event.id,
209+
status=(
210+
EventStatusCode.NotFound,
211+
f"could not find source: {event.target.source_uuid}",
212+
),
213+
)
214+
215+
utils.make_star_true(source.filesystem_id)
216+
db.session.commit()
217+
db.session.refresh(source)
218+
219+
return EventResult(
220+
event_id=event.id,
221+
status=(EventStatusCode.OK, None),
222+
sources={source.uuid: source},
223+
)
224+
225+
@staticmethod
226+
def handle_source_unstarred(event: Event) -> EventResult:
227+
try:
228+
source = Source.query.filter(Source.uuid == event.target.source_uuid).one()
229+
except NoResultFound:
230+
return EventResult(
231+
event_id=event.id,
232+
status=(
233+
EventStatusCode.NotFound,
234+
f"could not find source: {event.target.source_uuid}",
235+
),
236+
)
237+
238+
utils.make_star_false(source.filesystem_id)
239+
db.session.commit()
240+
db.session.refresh(source)
241+
242+
return EventResult(
243+
event_id=event.id,
244+
status=(EventStatusCode.OK, None),
245+
sources={source.uuid: source},
246+
)
247+
248+
@staticmethod
249+
def handle_item_seen(event: Event) -> EventResult:
250+
item = find_item(event.target.item_uuid)
251+
if item is None:
252+
return EventResult(
253+
event_id=event.id,
254+
status=(EventStatusCode.NotFound, f"could not find item: {event.target.item_uuid}"),
255+
)
256+
257+
# Mark it as seen
258+
utils.mark_seen([item], session.get_user())
259+
260+
# Refresh and return
261+
source = item.source
262+
db.session.refresh(source)
263+
db.session.refresh(item)
264+
265+
return EventResult(
266+
event_id=event.id,
267+
status=(EventStatusCode.OK, None),
268+
sources={source.uuid: source},
269+
items={item.uuid: item},
270+
)
271+
272+
273+
def find_item(item_uuid: ItemUUID) -> Submission | Reply | None:
274+
submission = Submission.query.filter(Submission.uuid == item_uuid).one_or_none()
275+
reply = Reply.query.filter(Reply.uuid == item_uuid).one_or_none()
276+
277+
if submission and reply:
278+
# Fail if we get unlucky and hit a UUID collision between the
279+
# `Submission` and `Reply` tables. This is vanishingly unlikely,
280+
# but SQLite can't enforce uniqueness between them.
281+
raise MultipleResultsFound(f"found {item_uuid} in both submissions and replies")
282+
283+
return submission or reply

securedrop/journalist_app/api2/shared.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,14 @@
33
(journalist_app.api) and shared with the v2 Journalist API (journalist_app.api2)
44
"""
55

6+
import hashlib
67
import os
8+
from typing import Mapping
79
from uuid import UUID
810

911
from db import db
12+
from flask import json
13+
from journalist_app.api2.types import Version
1014
from journalist_app.sessions import session
1115
from models import (
1216
InvalidUUID,
@@ -18,6 +22,20 @@
1822
from store import Storage
1923

2024

25+
def json_version(d: Mapping) -> Version:
26+
"""
27+
Calculate the version (BLAKE2s digest) of the normalized JSON representation
28+
of the dictionary ``d``.
29+
30+
We use BLAKE2s here because SHA-256 is too slow (we don't care about
31+
cryptographic security) and CRC-32 is too collision-prone (we're not merely
32+
checksumming for transmission integrity).
33+
"""
34+
s = json.dumps(d, separators=[",", ":"], sort_keys=True)
35+
b = s.encode("utf-8")
36+
return Version(hashlib.blake2s(b).hexdigest())
37+
38+
2139
def save_reply(source: Source, data: dict) -> Reply:
2240
source.interaction_count += 1
2341
filename = Storage.get_default().save_pre_encrypted_reply(

securedrop/journalist_app/api2/types.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,14 +26,25 @@
2626
class EventType(StrEnum):
2727
REPLY_SENT = auto()
2828
ITEM_DELETED = auto()
29+
ITEM_SEEN = auto()
30+
SOURCE_DELETED = auto()
31+
SOURCE_CONVERSATION_DELETED = auto()
32+
SOURCE_STARRED = auto()
33+
SOURCE_UNSTARRED = auto()
2934

3035

3136
class EventStatusCode(IntEnum):
3237
Processing = 102
3338
OK = 200
39+
# We already saw and processed this event
3440
AlreadyReported = 208
3541
BadRequest = 400
42+
# The target UUID doesn't exist (non-deletion requests)
3643
NotFound = 404
44+
# Provided version is out of date and it was a deletion request
45+
Conflict = 409
46+
# The target UUID doesn't exist and it was a deletion request
47+
Gone = 410
3748
NotImplemented = 501
3849

3950

0 commit comments

Comments
 (0)