Skip to content

Commit

Permalink
Merge pull request #64 from ntoll/doc-download
Browse files Browse the repository at this point in the history
File download UI / API plumbing
  • Loading branch information
ntoll authored Oct 29, 2018
2 parents 456f248 + c277e05 commit f25cf5a
Show file tree
Hide file tree
Showing 14 changed files with 433 additions and 53 deletions.
33 changes: 14 additions & 19 deletions Pipfile.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions securedrop_client/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@

def init(sdc_home: str) -> None:
safe_mkdir(sdc_home)
safe_mkdir(sdc_home, 'data')


def excepthook(*exc_args):
Expand Down
25 changes: 20 additions & 5 deletions securedrop_client/gui/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ def __init__(self):
widget_layout.addWidget(self.tool_bar, 1)
widget_layout.addWidget(self.main_view, 6)
self.setCentralWidget(self.widget)
self.current_source = None # Tracks which source is shown
self.show()
self.autosize_window()

Expand Down Expand Up @@ -150,15 +151,30 @@ def on_source_changed(self):
source_item = self.main_view.source_list.currentItem()
source_widget = self.main_view.source_list.itemWidget(source_item)
if source_widget:
self.show_conversation_for(source_widget.source)
self.current_source = source_widget.source
self.show_conversation_for(self.current_source)

def show_conversation_for(self, source):
"""
TODO: Finish this...
Show conversation of messages and replies between a source and
journalists.
"""
conversation = ConversationView(self)
conversation.setup(self.controller)
conversation.add_message('Source name: {}'.format(
source.journalist_designation))

# Display each conversation item in the source collection.
for conversation_item in source.collection:
if conversation_item.filename.endswith('msg.gpg'):
# TODO: Decrypt and display the message
pass
elif conversation_item.filename.endswith('reply.gpg'):
# TODO: Decrypt and display the reply
pass
else:
conversation.add_file(source, conversation_item)

conversation.add_message('Hello, hello, is this thing switched on?')
conversation.add_reply('Yes, I can hear you loud and clear!')
conversation.add_reply('How can I help?')
Expand Down Expand Up @@ -193,10 +209,9 @@ def show_conversation_for(self, source):
"into this. Also: someone I work with heard "
"him on the phone once, talking about his "
"'time' at Jackson—that contradicts his "
"resume. It really seems fishy.",
['fishy_cv.PDF (234Kb)', ])
"resume. It really seems fishy.")
conversation.add_reply("THIS IS IT THIS IS THE TAPE EVERYONE'S "
"LOOKING FOR!!!", ['filename.pdf (32Kb)', ])
"LOOKING FOR!!!")
conversation.add_reply("Hello: I read your story on Sally Dale, and "
"her lawsuit against the St. Joseph's "
"Orphanage. My great-aunt was one of the nuns "
Expand Down
48 changes: 38 additions & 10 deletions securedrop_client/gui/widgets.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
QPushButton, QVBoxLayout, QLineEdit, QScrollArea,
QPlainTextEdit, QSpacerItem, QSizePolicy, QDialog)
from securedrop_client.resources import load_svg, load_image
from securedrop_client.utils import humanize_filesize


logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -426,17 +427,30 @@ def __init__(self, message):

class FileWidget(QWidget):
"""
Represents a file attached to a message.
Represents a file.
"""

def __init__(self, text, align):
def __init__(self, source_db_object, submission_db_object,
controller, align="left"):
"""
Given some text, an indication of alignment ('left' or 'right') and
a reference to the controller, make something to display a file.
Align is set to left by default because currently SecureDrop can only
accept files from sources to journalists.
"""
super().__init__()
self.controller = controller
self.source = source_db_object
self.submission = submission_db_object
layout = QHBoxLayout()
icon = QLabel()
icon.setPixmap(load_image('file.png'))
description = QLabel(text)
if submission_db_object.is_downloaded:
description = QLabel("Open")
else:
human_filesize = humanize_filesize(self.submission.size)
description = QLabel("Download ({})".format(human_filesize))
if align is not "left":
# Float right...
layout.addStretch(5)
Expand All @@ -447,6 +461,12 @@ def __init__(self, text, align):
layout.addStretch(5)
self.setLayout(layout)

def mouseDoubleClickEvent(self, e):
"""
Handle a double-click via the program logic.
"""
self.controller.on_file_click(self.source, self.submission)


class ConversationView(QWidget):
"""
Expand All @@ -470,20 +490,28 @@ def __init__(self, parent):
main_layout.addWidget(scroll)
self.setLayout(main_layout)

def add_message(self, message, files=None):
def setup(self, controller):
"""
Ensure there's a reference to program logic.
"""
self.controller = controller

def add_file(self, source_db_object, submission_db_object):
"""
Add a file from the source.
"""
self.conversation_layout.addWidget(
FileWidget(source_db_object, submission_db_object,
self.controller))

def add_message(self, message):
"""
Add a message from the source.
"""
self.conversation_layout.addWidget(MessageWidget(message))
if files:
for f in files:
self.conversation_layout.addWidget(FileWidget(f, 'left'))

def add_reply(self, reply, files=None):
"""
Add a reply from a journalist.
"""
self.conversation_layout.addWidget(ReplyWidget(reply))
if files:
for f in files:
self.conversation_layout.addWidget(FileWidget(f, 'right'))
60 changes: 59 additions & 1 deletion securedrop_client/logic.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
import sdclientapi
import arrow
from securedrop_client import storage
from securedrop_client import models
from securedrop_client.utils import check_dir_permissions
from PyQt5.QtCore import QObject, QThread, pyqtSignal, QTimer

Expand Down Expand Up @@ -100,6 +101,8 @@ def __init__(self, hostname, gui, session, home: str) -> None:
self.session = session # Reference to the SqlAlchemy session.
self.api_thread = None # Currently active API call thread.
self.sync_flag = os.path.join(home, 'sync_flag')
self.home = home # The "home" directory for client files.
self.data_dir = os.path.join(self.home, 'data') # File data.

def setup(self):
"""
Expand All @@ -122,7 +125,8 @@ def setup(self):
self.sync_timer.timeout.connect(self.update_sync)
self.sync_timer.start(30000)

def call_api(self, function, callback, timeout, *args, **kwargs):
def call_api(self, function, callback, timeout, *args, current_object=None,
**kwargs):
"""
Calls the function in a non-blocking manner. Upon completion calls the
callback with the result. Calls timeout if the API call emits a
Expand All @@ -133,6 +137,7 @@ def call_api(self, function, callback, timeout, *args, **kwargs):
self.api_thread = QThread(self.gui)
self.api_runner = APICallRunner(function, *args, **kwargs)
self.api_runner.moveToThread(self.api_thread)
self.api_runner.current_object = current_object
self.api_runner.call_finished.connect(callback)
self.api_runner.timeout.connect(timeout)
self.finish_api_call.connect(self.api_runner.on_cancel_timeout)
Expand Down Expand Up @@ -313,3 +318,56 @@ def set_status(self, message, duration=5000):
duration.
"""
self.gui.set_status(message, duration)

def on_file_click(self, source_db_object, message):
"""
Download the file associated with the associated message (which may
be a Submission or Reply).
"""
if isinstance(message, models.Submission):
# Handle submissions.
func = self.api.download_submission
sdk_object = sdclientapi.Submission(uuid=message.uuid)
sdk_object.filename = message.filename
sdk_object.source_uuid = source_db_object.uuid
elif isinstance(message, models.Reply):
# Handle journalist's replies.
func = self.api.download_reply
sdk_object = sdclientapi.Reply(uuid=message.uuid)
sdk_object.filename = message.filename
sdk_object.source_uuid = source_db_object.uuid
self.call_api(func, self.on_file_download,
self.on_download_timeout, sdk_object, self.data_dir,
current_object=message)

def on_file_download(self, result):
"""
Called when a file has downloaded. Cause a refresh to the conversation
view to display the contents of the new file.
"""
sha256sum, filename = self.api_runner.result
file_uuid = self.api_runner.current_object.uuid
server_filename = self.api_runner.current_object.filename
self.call_reset()
if result:
# The filename contains the location where the file has been
# stored. On non-Qubes OSes, this will be the data directory.
# On Qubes OS, this will a ~/QubesIncoming directory. In case
# we are on Qubes, we should move the file to the data directory
# and name it the same as the server (e.g. spotless-tater-msg.gpg).
os.rename(filename, os.path.join(self.data_dir, server_filename))
storage.mark_file_as_downloaded(file_uuid, self.session)

# Refresh the current source conversation, bearing in mind
# that the user may have navigated to another source.
self.gui.show_conversation_for(self.gui.current_source)
else:
# Update the UI in some way to indicate a failure state.
self.set_status("Failed to download file, please try again.")

def on_download_timeout(self):
"""
Called when downloading a file has timed out.
"""
# Update the status bar to indicate a failure state.
self.set_status("Connection to server timed out, please try again.")
10 changes: 10 additions & 0 deletions securedrop_client/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,16 @@ def __init__(self, uuid, journalist_designation, is_flagged, public_key,
def __repr__(self):
return '<Source {}>'.format(self.journalist_designation)

@property
def collection(self):
"""Return the list of submissions and replies for this source, sorted
in ascending order by the filename/interaction count."""
collection = []
collection.extend(self.submissions)
collection.extend(self.replies)
collection.sort(key=lambda x: int(x.filename.split('-')[0]))
return collection


class Submission(Base):
__tablename__ = 'submissions'
Expand Down
12 changes: 12 additions & 0 deletions securedrop_client/storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -234,3 +234,15 @@ def find_or_create_user(uuid, username, session):
session.add(new_user)
session.commit()
return new_user


def mark_file_as_downloaded(uuid, session):
"""Mark file as downloaded in the database. The file itself will be
stored in the data directory.
"""
submission_db_object = session.query(Submission) \
.filter_by(uuid=uuid) \
.one_or_none()
submission_db_object.is_downloaded = True
session.add(submission_db_object)
session.commit()
Loading

0 comments on commit f25cf5a

Please sign in to comment.