Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Delete conversation files and records as soon as server confirms scheduled deletion #1432

Merged
merged 6 commits into from
Mar 14, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions alembic/versions/eff1387cfd0b_add_deletedconversation_table.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
"""add deletedconversation table

Revision ID: eff1387cfd0b
Revises: bd57477f19a2
Create Date: 2022-02-24 13:11:22.227528

"""
import sqlalchemy as sa

from alembic import op

# revision identifiers, used by Alembic.
revision = "eff1387cfd0b"
down_revision = "bd57477f19a2"
branch_labels = None
depends_on = None


def upgrade():
# Add deletedconversation table to manage locally-deleted records
# and ensure they do not get re-downloaded to the database during
# a network race condition.
# UUID was chosen as PK to avoid storing data such as source_id that
# could divulge information about the source account creation timeline.
# Note that records in this table are purged every 15 seconds.
op.create_table(
"deletedconversation",
sa.Column("uuid", sa.String(length=36), nullable=False),
sa.PrimaryKeyConstraint("uuid", name=op.f("pk_deletedconversation")),
)


def downgrade():
op.drop_table("deletedconversation")
20 changes: 20 additions & 0 deletions securedrop_client/db.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,26 @@ def seen(self) -> bool:
return True


class DeletedConversation(Base):
"""
Table that stores only source UUIDs for conversations (files and messages) that
have been deleted locally, to prevent them from being re-added to the Messages and
Replies tables during a 'stale sync' (network race condition).
"""

__tablename__ = "deletedconversation"

uuid = Column(String(36), primary_key=True, nullable=False)

def __repr__(self) -> str:
return "DeletedConversation (source {})".format(self.uuid)

def __init__(self, **kwargs: Any) -> None:
if "uuid" not in kwargs:
raise TypeError("Keyword argument 'uuid' (source UUID) required")
super().__init__(**kwargs)


class Message(Base):

__tablename__ = "messages"
Expand Down
69 changes: 16 additions & 53 deletions securedrop_client/gui/widgets.py
Original file line number Diff line number Diff line change
Expand Up @@ -1329,11 +1329,6 @@ def set_snippet(
if self.deleting or self.deleting_conversation:
return

# If the sync started before the deletion finished, then the sync is stale and we do
# not want to update the source widget.
if self.sync_started_timestamp < self.deletion_scheduled_timestamp:
return

# If the source collection is empty yet the interaction_count is greater than zero, then we
# known that the conversation has been deleted.
if not self.source.server_collection:
Expand Down Expand Up @@ -2944,18 +2939,15 @@ def _on_conversation_deletion_successful(self, source_uuid: str, timestamp: date
logger.debug(f"Could not update ConversationView: {e}")

def update_deletion_markers(self) -> None:
try:
if self.source.collection:
self.scroll.show()
if self.source.collection[0].file_counter > 1:
self.deleted_conversation_marker.hide()
self.deleted_conversation_items_marker.show()
elif self.source.interaction_count > 0:
self.scroll.hide()
self.deleted_conversation_items_marker.hide()
self.deleted_conversation_marker.show()
except sqlalchemy.exc.InvalidRequestError as e:
logger.debug(f"Could not Update deletion markers in the ConversationView: {e}")
if self.source.collection:
self.scroll.show()
if self.source.collection[0].file_counter > 1:
self.deleted_conversation_marker.hide()
self.deleted_conversation_items_marker.show()
elif self.source.interaction_count > 0:
self.scroll.hide()
self.deleted_conversation_items_marker.hide()
self.deleted_conversation_marker.show()

def update_conversation(self, collection: list) -> None:
"""
Expand All @@ -2976,11 +2968,6 @@ def update_conversation(self, collection: list) -> None:
passed into this method in case of a mismatch between where the widget
has been and now is in terms of its index in the conversation.
"""
# If the sync started before the deletion finished, then the sync is stale and we do
# not want to update the conversation.
if self.sync_started_timestamp < self.deletion_scheduled_timestamp:
return

self.controller.session.refresh(self.source)

# Keep a temporary copy of the current conversation so we can delete any
Expand Down Expand Up @@ -3116,40 +3103,16 @@ def add_reply(self, reply: Union[DraftReply, Reply], sender: User, index: int) -
self.scroll.add_widget_to_conversation(index, conversation_item, Qt.AlignRight)
self.current_messages[reply.uuid] = conversation_item

def add_reply_from_reply_box(self, uuid: str, content: str) -> None:
"""
Add a reply from the reply box.
"""
if not self.controller.authenticated_user:
logger.error("User is no longer authenticated so cannot send reply.")
return

index = len(self.current_messages)
conversation_item = ReplyWidget(
self.controller,
uuid,
content,
"PENDING",
self.controller.reply_ready,
self.controller.reply_download_failed,
self.controller.reply_succeeded,
self.controller.reply_failed,
index,
self.scroll.widget().width(),
self.controller.authenticated_user,
True,
)
self.scroll.add_widget_to_conversation(index, conversation_item, Qt.AlignRight)
self.current_messages[uuid] = conversation_item

def on_reply_sent(self, source_uuid: str, reply_uuid: str, reply_text: str) -> None:
def on_reply_sent(self, source_uuid: str) -> None:
"""
Add the reply text sent from ReplyBoxWidget to the conversation.
"""
self.reply_flag = True
if source_uuid == self.source.uuid:
self.add_reply_from_reply_box(reply_uuid, reply_text)
self.update_deletion_markers()
try:
self.update_conversation(self.source.collection)
except sqlalchemy.exc.InvalidRequestError as e:
logger.debug(e)


class SourceConversationWrapper(QWidget):
Expand Down Expand Up @@ -3291,7 +3254,7 @@ class ReplyBoxWidget(QWidget):
A textbox where a journalist can enter a reply.
"""

reply_sent = pyqtSignal(str, str, str)
reply_sent = pyqtSignal(str)

def __init__(self, source: Source, controller: Controller) -> None:
super().__init__()
Expand Down Expand Up @@ -3388,7 +3351,7 @@ def send_reply(self) -> None:
self.text_edit.setText("")
reply_uuid = str(uuid4())
self.controller.send_reply(self.source.uuid, reply_uuid, reply_text)
self.reply_sent.emit(self.source.uuid, reply_uuid, reply_text)
self.reply_sent.emit(self.source.uuid)

@pyqtSlot(bool)
def _on_authentication_changed(self, authenticated: bool) -> None:
Expand Down
7 changes: 5 additions & 2 deletions securedrop_client/logic.py
Original file line number Diff line number Diff line change
Expand Up @@ -1022,9 +1022,12 @@ def on_file_download_failure(self, exception: Exception) -> None:
def on_delete_conversation_success(self, uuid: str) -> None:
"""
If the source collection has been successfully scheduled for deletion on the server, emit a
signal and sync.
signal.
"""
logger.info("Conversation %s successfully deleted at server", uuid)
logger.info("Conversation %s successfully scheduled for deletion at server", uuid)

# Delete conversation locally to ensure that it does not remain on disk until next sync
storage.delete_local_conversation_by_source_uuid(self.session, uuid, self.data_dir)
self.conversation_deletion_successful.emit(uuid, datetime.utcnow())

def on_delete_conversation_failure(self, e: Exception) -> None:
Expand Down
Loading