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

File download UI / API plumbing #64

Merged
merged 4 commits into from
Oct 29, 2018
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
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')
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍



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.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

though we will just indicate that the file is downloaded instead of opening (the actual opening logic will be dealt with in #20), i.e. "download" before DL and "view" after DL (following the language in the relevant snippets from wireframe):

screen shot 2018-10-24 at 6 30 06 pm

screen shot 2018-10-24 at 6 30 16 pm

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right... so I just want to be absolutely certain I grok this:

  • There are two sorts of files that can be downloaded and then decrypted: 1) Plaintext files containing a message (from now on "messages"). 2) Attachments files (such a images or PDF docs -- from now on "attachments").
  • If a file isn't downloaded we just show that it's ready to be downloaded and encrypted. I'm assuming there's a way to distinguish between messages and attachments due to filename naming conventions, right?
  • Once a file (no matter if it is a message or attachment) is downloaded, then this function we will display the contents of the downloaded and decrypted messages as the speech bubbles and the downloaded attachment as a file widget in a viewable/exportable state.
  • All pending-download/encryption messages/attachments will just look like the "download" image in your comment.

Sorry for the painful description of detail, but it's important to get this written out somewhere. ;-)

"""
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