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

Send replies from a journalist to a source #240

Merged
merged 8 commits into from
Feb 8, 2019
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