Skip to content

Commit

Permalink
Merge pull request #240 from freedomofpress/send-replies
Browse files Browse the repository at this point in the history
Send replies from a journalist to a source
  • Loading branch information
redshiftzero authored Feb 8, 2019
2 parents d51748e + 107d153 commit aa8c9c6
Show file tree
Hide file tree
Showing 8 changed files with 402 additions and 38 deletions.
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,11 @@ This client is under active development and currently supports a minimal feature
- the download and decryption of files, messages, and replies (using [Qubes split-gpg](https://www.qubes-os.org/doc/split-gpg/))
- the display of decrypted messages and replies in a new conversation view
- the opening of all files in individual, non-networked, Qubes disposable VMs
- replying to sources
- deleting sources

Features to be added include:

- Reply to sources (encrypted client-side) - tracked in https://github.com/freedomofpress/securedrop-client/issues/16. These replies will be encrypted both to individual sources, and to the submission key of the instance. Source public keys are provided by the journalist API.
- Deletion of source collection - tracked in https://github.com/freedomofpress/securedrop-client/issues/18. This will delete all files associated with a source both locally and on the server.
- Export workflows - tracked in https://github.com/freedomofpress/securedrop-client/issues/21. These workflows (initially a USB drive) enable a journalist to transfer a document out of the Qubes workstation and to another computer for further analysis or sharing with the rest of the newsroom.

## Getting Started
Expand Down
10 changes: 1 addition & 9 deletions securedrop_client/db.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,20 +120,12 @@ class Reply(Base):
"User", backref=backref('replies', order_by=id))

filename = Column(String(255), nullable=False)
size = Column(Integer, nullable=False)
size = Column(Integer)

# This is whether the reply has been downloaded in the local database.
is_downloaded = Column(Boolean(name='ck_replies_is_downloaded'),
default=False)

def __init__(self, uuid, journalist, source, filename, size):
self.uuid = uuid
self.journalist_id = journalist.id
self.source_id = source.id
self.filename = filename
self.size = size
self.is_downloaded = False

def __repr__(self):
return '<Reply {}>'.format(self.filename)

Expand Down
87 changes: 79 additions & 8 deletions securedrop_client/gui/widgets.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@
from PyQt5.QtGui import QIcon
from PyQt5.QtWidgets import QListWidget, QLabel, QWidget, QListWidgetItem, QHBoxLayout, \
QPushButton, QVBoxLayout, QLineEdit, QScrollArea, QDialog, QAction, QMenu, \
QMessageBox, QToolButton, QSizePolicy
QMessageBox, QToolButton, QSizePolicy, QTextEdit
from uuid import uuid4

from securedrop_client.db import Source
from securedrop_client.logic import Client
Expand All @@ -37,8 +38,6 @@
class ToolBar(QWidget):
"""
Represents the tool bar across the top of the user interface.
ToDo: this is a work in progress and will be updated soon.
"""

def __init__(self, parent):
Expand Down Expand Up @@ -505,7 +504,7 @@ def __init__(self, message_id: str, text: str, update_signal) -> None:
def _update_text(self, message_id: str, text: str) -> None:
"""
Conditionally update this SpeechBubble's text if and only if the message_id of the emitted
signal matche the message_id of this speech bubble.
signal matches the message_id of this speech bubble.
"""
if message_id == self.message_id:
self.message.setText(html.escape(text, quote=False))
Expand Down Expand Up @@ -568,14 +567,45 @@ class ReplyWidget(ConversationWidget):
Represents a reply to a source.
"""

def __init__(self, message_id: str, message: str, update_signal) -> None:
def __init__(
self,
message_id: str,
message: str,
update_signal,
message_succeeded_signal,
message_failed_signal,
) -> None:
super().__init__(message_id,
message,
update_signal,
align="right")
self.message_id = message_id
self.setStyleSheet("""
background-color: #2299EE;
""")
message_succeeded_signal.connect(self._on_reply_success)
message_failed_signal.connect(self._on_reply_failure)

@pyqtSlot(str)
def _on_reply_success(self, message_id: str) -> None:
"""
Conditionally update this ReplyWidget's state if and only if the message_id of the emitted
signal matches the message_id of this widget.
"""
if message_id == self.message_id:
logger.debug('Message {} succeeded'.format(message_id))

@pyqtSlot(str)
def _on_reply_failure(self, message_id: str) -> None:
"""
Conditionally update this ReplyWidget's state if and only if the message_id of the emitted
signal matches the message_id of this widget.
"""
if message_id == self.message_id:
logger.debug('Message {} failed'.format(message_id))
self.setStyleSheet("""
background-color: #FF3E3C;
""")


class FileWidget(QWidget):
Expand Down Expand Up @@ -725,7 +755,12 @@ def add_reply(self, message_id: str, reply: str, files=None) -> None:
Add a reply from a journalist.
"""
self.conversation_layout.addWidget(
ReplyWidget(message_id, reply, self.controller.reply_sync.reply_downloaded))
ReplyWidget(message_id,
reply,
self.controller.reply_sync.reply_downloaded,
self.controller.reply_succeeded,
self.controller.reply_failed,
))


class SourceConversationWrapper(QWidget):
Expand All @@ -736,14 +771,50 @@ class SourceConversationWrapper(QWidget):

def __init__(self, source: Source, sdc_home: str, controller: Client, parent=None) -> None:
super().__init__(parent)
self.source = source
self.controller = controller
self.layout = QVBoxLayout()
self.setLayout(self.layout)

self.conversation = ConversationView(source, sdc_home, controller, parent=self)
self.source_profile = SourceProfileShortWidget(source, controller)
self.conversation = ConversationView(self.source, sdc_home, self.controller, parent=self)
self.source_profile = SourceProfileShortWidget(self.source, self.controller)
self.reply_box = ReplyBoxWidget(self)

self.layout.addWidget(self.source_profile)
self.layout.addWidget(self.conversation)
self.layout.addWidget(self.reply_box)

def send_reply(self, message: str) -> None:
msg_uuid = str(uuid4())
self.conversation.add_reply(msg_uuid, message)
self.controller.send_reply(self.source.uuid, msg_uuid, message)


class ReplyBoxWidget(QWidget):
"""
A textbox where a journalist can enter a reply.
"""

def __init__(self, conversation: SourceConversationWrapper) -> None:
super().__init__()
self.conversation = conversation

self.text_edit = QTextEdit()

self.send_button = QPushButton('Send')
self.send_button.clicked.connect(self.send_reply)

layout = QHBoxLayout()
layout.addWidget(self.text_edit)
layout.addWidget(self.send_button)
self.setLayout(layout)

def send_reply(self) -> None:
msg = self.text_edit.toPlainText().strip()
if not msg:
return
self.conversation.send_reply(msg)
self.text_edit.clear()


class DeleteSourceAction(QAction):
Expand Down
62 changes: 60 additions & 2 deletions securedrop_client/logic.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,12 @@
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
"""
import os
import arrow
import logging
import os
import sdclientapi
import shutil
import arrow
import traceback
import uuid
from securedrop_client import storage
from securedrop_client import db
Expand Down Expand Up @@ -89,6 +90,18 @@ class Client(QObject):

sync_events = pyqtSignal(str)

"""
Signal that notifies that a reply was accepted by the server. Emits the reply's UUID as a
string.
"""
reply_succeeded = pyqtSignal(str)

"""
Signal that notifies that a reply failed to be accepted by the server. Emits the reply's UUID
as a string.
"""
reply_failed = pyqtSignal(str)

def __init__(self, hostname, gui, session,
home: str, proxy: bool = True) -> None:
"""
Expand Down Expand Up @@ -642,3 +655,48 @@ def delete_source(self, source):
self._on_delete_action_timeout,
source
)

def send_reply(self, source_uuid: str, msg_uuid: str, message: str) -> None:
sdk_source = sdclientapi.Source(uuid=source_uuid)

try:
encrypted_reply = self.gpg.encrypt_to_source(source_uuid, message)
except Exception:
tb = traceback.format_exc()
logger.error('Failed to encrypt to source {}:\n'.format(source_uuid, tb))
self.reply_failed.emit(msg_uuid)
else:
# Guard against calling the API if we're not logged in
if self.api:
self.call_api(
self.api.reply_source,
self._on_reply_complete,
self._on_reply_timeout,
sdk_source,
encrypted_reply,
msg_uuid,
current_object=(source_uuid, msg_uuid),
)
else:
logger.error('not logged in - not implemented!') # pragma: no cover
self.reply_failed.emit(msg_uuid) # pragma: no cover

def _on_reply_complete(self, result, current_object: (str, str)) -> None:
source_uuid, reply_uuid = current_object
source = self.session.query(db.Source).filter_by(uuid=source_uuid).one()
if isinstance(result, sdclientapi.Reply):
reply_db_object = db.Reply(
uuid=result.uuid,
source_id=source.id,
journalist_id=self.api.token['journalist_uuid'],
filename=result.filename,
)
self.session.add(reply_db_object)
self.session.commit()
self.reply_succeeded.emit(reply_uuid)
else:
self.reply_failed.emit(reply_uuid)

def _on_reply_timeout(self, current_object: (str, str)) -> None:
_, reply_uuid = current_object
self.reply_failed.emit(reply_uuid)
15 changes: 7 additions & 8 deletions securedrop_client/message_sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@

import time
import logging
import traceback
import sdclientapi.sdlocalobjects as sdkobjects

from PyQt5.QtCore import QObject, pyqtSignal
Expand Down Expand Up @@ -88,10 +89,9 @@ def run(self, loop=True):
storage.mark_file_as_downloaded)
self.message_downloaded.emit(db_submission.uuid,
get_data(self.home, db_submission.filename))
except Exception as e:
logger.critical(
"Exception while downloading submission! {}".format(e)
)
except Exception:
tb = traceback.format_exc()
logger.critical("Exception while downloading submission!\n{}".format(tb))

logger.debug('Completed message sync.')

Expand Down Expand Up @@ -139,10 +139,9 @@ def run(self, loop=True):
storage.mark_reply_as_downloaded)
self.reply_downloaded.emit(db_reply.uuid,
get_data(self.home, db_reply.filename))
except Exception as e:
logger.critical(
"Exception while downloading reply! {}".format(e)
)
except Exception:
tb = traceback.format_exc()
logger.critical("Exception while downloading reply!\n{}".format(tb))

logger.debug('Completed reply sync.')

Expand Down
6 changes: 5 additions & 1 deletion securedrop_client/storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,11 @@ def update_replies(remote_replies, local_replies, session, data_dir):
source = session.query(Source).filter_by(uuid=source_uuid)[0]
user = find_or_create_user(reply.journalist_uuid,
reply.journalist_username, session)
nr = Reply(reply.uuid, user, source, reply.filename, reply.size)
nr = Reply(uuid=reply.uuid,
journalist_id=user.id,
source_id=source.id,
filename=reply.filename,
size=reply.size)
session.add(nr)
logger.info('Added new reply {}'.format(reply.uuid))

Expand Down
Loading

0 comments on commit aa8c9c6

Please sign in to comment.