We are currently solving the following issue within our repository. Here is the issue text:
--- BEGIN ISSUE ---
If a source's store directory is missing, subsequent submission attempts will fail with a FileNotFoundError exception
## Description

As per title.

## Steps to Reproduce

(in dev)
- log in to the SI and submit.
- in a shell on the dev docker container (`docker exec -it securedrop-dev-0 bash`) find and delete the source's directory under `/var/lib/securedrop/store/` 
- submit from the SI again as the same source 

## Expected Behavior
Either:
- submission succeeds and directory is recreated,
Or:
- Submission fails with an informative error

## Actual Behavior
uncaught FileNotFoundError and a stack trace (in dev)



Suggestions to fix, any other relevant information.


--- END ISSUE ---

Below are some code segments, each from a relevant file. One or more of these files may contain bugs.
--- BEGIN FILES ---
Path: `securedrop/source_app/main.py`
Content:
```
1 import operator
2 import os
3 import io
4 
5 from base64 import urlsafe_b64encode
6 from datetime import datetime
7 from typing import Union
8 
9 import werkzeug
10 from flask import (Blueprint, render_template, flash, redirect, url_for, g,
11                    session, current_app, request, Markup, abort)
12 from flask_babel import gettext
13 from sqlalchemy.exc import IntegrityError
14 
15 import store
16 
17 from db import db
18 from models import Source, Submission, Reply, get_one_or_else
19 from sdconfig import SDConfig
20 from source_app.decorators import login_required
21 from source_app.utils import (logged_in, generate_unique_codename,
22                               async_genkey, normalize_timestamps,
23                               valid_codename, get_entropy_estimate)
24 from source_app.forms import LoginForm, SubmissionForm
25 
26 
27 def make_blueprint(config: SDConfig) -> Blueprint:
28     view = Blueprint('main', __name__)
29 
30     @view.route('/')
31     def index() -> str:
32         return render_template('index.html')
33 
34     @view.route('/generate', methods=('GET', 'POST'))
35     def generate() -> Union[str, werkzeug.Response]:
36         if logged_in():
37             flash(gettext(
38                 "You were redirected because you are already logged in. "
39                 "If you want to create a new account, you should log out "
40                 "first."),
41                   "notification")
42             return redirect(url_for('.lookup'))
43 
44         codename = generate_unique_codename(config)
45 
46         # Generate a unique id for each browser tab and associate the codename with this id.
47         # This will allow retrieval of the codename displayed in the tab from which the source has
48         # clicked to proceed to /generate (ref. issue #4458)
49         tab_id = urlsafe_b64encode(os.urandom(64)).decode()
50         codenames = session.get('codenames', {})
51         codenames[tab_id] = codename
52         session['codenames'] = codenames
53 
54         session['new_user'] = True
55         return render_template('generate.html', codename=codename, tab_id=tab_id)
56 
57     @view.route('/create', methods=['POST'])
58     def create() -> werkzeug.Response:
59         if session.get('logged_in', False):
60             flash(gettext("You are already logged in. Please verify your codename above as it " +
61                           "may differ from the one displayed on the previous page."),
62                   'notification')
63         else:
64             tab_id = request.form['tab_id']
65             codename = session['codenames'][tab_id]
66             session['codename'] = codename
67 
68             del session['codenames']
69 
70             filesystem_id = current_app.crypto_util.hash_codename(codename)
71             try:
72                 source = Source(filesystem_id, current_app.crypto_util.display_id())
73             except ValueError as e:
74                 current_app.logger.error(e)
75                 flash(
76                     gettext("There was a temporary problem creating your account. "
77                             "Please try again."
78                             ),
79                     'error'
80                 )
81                 return redirect(url_for('.index'))
82 
83             db.session.add(source)
84             try:
85                 db.session.commit()
86             except IntegrityError as e:
87                 db.session.rollback()
88                 current_app.logger.error(
89                     "Attempt to create a source with duplicate codename: %s" %
90                     (e,))
91 
92                 # Issue 2386: don't log in on duplicates
93                 del session['codename']
94 
95                 # Issue 4361: Delete 'logged_in' if it's in the session
96                 try:
97                     del session['logged_in']
98                 except KeyError:
99                     pass
100 
101                 abort(500)
102             else:
103                 os.mkdir(current_app.storage.path(filesystem_id))
104 
105             session['logged_in'] = True
106         return redirect(url_for('.lookup'))
107 
108     @view.route('/lookup', methods=('GET',))
109     @login_required
110     def lookup() -> str:
111         replies = []
112         source_inbox = Reply.query.filter(Reply.source_id == g.source.id) \
113                                   .filter(Reply.deleted_by_source == False).all()  # noqa
114 
115         for reply in source_inbox:
116             reply_path = current_app.storage.path(
117                 g.filesystem_id,
118                 reply.filename,
119             )
120             try:
121                 with io.open(reply_path, "rb") as f:
122                     contents = f.read()
123                 reply_obj = current_app.crypto_util.decrypt(g.codename, contents)
124                 reply.decrypted = reply_obj
125             except UnicodeDecodeError:
126                 current_app.logger.error("Could not decode reply %s" %
127                                          reply.filename)
128             except FileNotFoundError:
129                 current_app.logger.error("Reply file missing: %s" %
130                                          reply.filename)
131             else:
132                 reply.date = datetime.utcfromtimestamp(
133                     os.stat(reply_path).st_mtime)
134                 replies.append(reply)
135 
136         # Sort the replies by date
137         replies.sort(key=operator.attrgetter('date'), reverse=True)
138 
139         # Generate a keypair to encrypt replies from the journalist
140         # Only do this if the journalist has flagged the source as one
141         # that they would like to reply to. (Issue #140.)
142         if not current_app.crypto_util.get_fingerprint(g.filesystem_id) and \
143                 g.source.flagged:
144             db_uri = current_app.config['SQLALCHEMY_DATABASE_URI']
145             async_genkey(current_app.crypto_util,
146                          db_uri,
147                          g.filesystem_id,
148                          g.codename)
149 
150         return render_template(
151             'lookup.html',
152             allow_document_uploads=current_app.instance_config.allow_document_uploads,
153             codename=g.codename,
154             replies=replies,
155             flagged=g.source.flagged,
156             new_user=session.get('new_user', None),
157             haskey=current_app.crypto_util.get_fingerprint(g.filesystem_id),
158             form=SubmissionForm(),
159         )
160 
161     @view.route('/submit', methods=('POST',))
162     @login_required
163     def submit() -> werkzeug.Response:
164         allow_document_uploads = current_app.instance_config.allow_document_uploads
165         form = SubmissionForm()
166         if not form.validate():
167             for field, errors in form.errors.items():
168                 for error in errors:
169                     flash(error, "error")
170             return redirect(url_for('main.lookup'))
171 
172         msg = request.form['msg']
173         fh = None
174         if allow_document_uploads and 'fh' in request.files:
175             fh = request.files['fh']
176 
177         # Don't submit anything if it was an "empty" submission. #878
178         if not (msg or fh):
179             if allow_document_uploads:
180                 flash(gettext(
181                     "You must enter a message or choose a file to submit."),
182                       "error")
183             else:
184                 flash(gettext("You must enter a message."), "error")
185             return redirect(url_for('main.lookup'))
186 
187         fnames = []
188         journalist_filename = g.source.journalist_filename
189         first_submission = g.source.interaction_count == 0
190 
191         if msg:
192             g.source.interaction_count += 1
193             fnames.append(
194                 current_app.storage.save_message_submission(
195                     g.filesystem_id,
196                     g.source.interaction_count,
197                     journalist_filename,
198                     msg))
199         if fh:
200             g.source.interaction_count += 1
201             fnames.append(
202                 current_app.storage.save_file_submission(
203                     g.filesystem_id,
204                     g.source.interaction_count,
205                     journalist_filename,
206                     fh.filename,
207                     fh.stream))
208 
209         if first_submission:
210             flash_message = render_template('first_submission_flashed_message.html')
211             flash(Markup(flash_message), "success")
212 
213         else:
214             if msg and not fh:
215                 html_contents = gettext('Thanks! We received your message.')
216             elif fh and not msg:
217                 html_contents = gettext('Thanks! We received your document.')
218             else:
219                 html_contents = gettext('Thanks! We received your message and '
220                                         'document.')
221 
222             flash_message = render_template(
223                 'next_submission_flashed_message.html',
224                 html_contents=html_contents
225             )
226             flash(Markup(flash_message), "success")
227 
228         new_submissions = []
229         for fname in fnames:
230             submission = Submission(g.source, fname)
231             db.session.add(submission)
232             new_submissions.append(submission)
233 
234         if g.source.pending:
235             g.source.pending = False
236 
237             # Generate a keypair now, if there's enough entropy (issue #303)
238             # (gpg reads 300 bytes from /dev/random)
239             entropy_avail = get_entropy_estimate()
240             if entropy_avail >= 2400:
241                 db_uri = current_app.config['SQLALCHEMY_DATABASE_URI']
242 
243                 async_genkey(current_app.crypto_util,
244                              db_uri,
245                              g.filesystem_id,
246                              g.codename)
247                 current_app.logger.info("generating key, entropy: {}".format(
248                     entropy_avail))
249             else:
250                 current_app.logger.warning(
251                         "skipping key generation. entropy: {}".format(
252                                 entropy_avail))
253 
254         g.source.last_updated = datetime.utcnow()
255         db.session.commit()
256 
257         for sub in new_submissions:
258             store.async_add_checksum_for_file(sub)
259 
260         normalize_timestamps(g.filesystem_id)
261 
262         return redirect(url_for('main.lookup'))
263 
264     @view.route('/delete', methods=('POST',))
265     @login_required
266     def delete() -> werkzeug.Response:
267         """This deletes the reply from the source's inbox, but preserves
268         the history for journalists such that they can view conversation
269         history.
270         """
271 
272         query = Reply.query.filter_by(
273             filename=request.form['reply_filename'],
274             source_id=g.source.id)
275         reply = get_one_or_else(query, current_app.logger, abort)
276         reply.deleted_by_source = True
277         db.session.add(reply)
278         db.session.commit()
279 
280         flash(gettext("Reply deleted"), "notification")
281         return redirect(url_for('.lookup'))
282 
283     @view.route('/delete-all', methods=('POST',))
284     @login_required
285     def batch_delete() -> werkzeug.Response:
286         replies = Reply.query.filter(Reply.source_id == g.source.id) \
287                              .filter(Reply.deleted_by_source == False).all()  # noqa
288         if len(replies) == 0:
289             current_app.logger.error("Found no replies when at least one was "
290                                      "expected")
291             return redirect(url_for('.lookup'))
292 
293         for reply in replies:
294             reply.deleted_by_source = True
295             db.session.add(reply)
296         db.session.commit()
297 
298         flash(gettext("All replies have been deleted"), "notification")
299         return redirect(url_for('.lookup'))
300 
301     @view.route('/login', methods=('GET', 'POST'))
302     def login() -> Union[str, werkzeug.Response]:
303         form = LoginForm()
304         if form.validate_on_submit():
305             codename = request.form['codename'].strip()
306             if valid_codename(codename):
307                 session.update(codename=codename, logged_in=True)
308                 return redirect(url_for('.lookup', from_login='1'))
309             else:
310                 current_app.logger.info(
311                         "Login failed for invalid codename")
312                 flash(gettext("Sorry, that is not a recognized codename."),
313                       "error")
314         return render_template('login.html', form=form)
315 
316     @view.route('/logout')
317     def logout() -> Union[str, werkzeug.Response]:
318         """
319         If a user is logged in, show them a logout page that prompts them to
320         click the New Identity button in Tor Browser to complete their session.
321         Otherwise redirect to the main Source Interface page.
322         """
323         if logged_in():
324 
325             # Clear the session after we render the message so it's localized
326             # If a user specified a locale, save it and restore it
327             session.clear()
328             session['locale'] = g.localeinfo.id
329 
330             return render_template('logout.html')
331         else:
332             return redirect(url_for('.index'))
333 
334     return view
335
```
--- END FILES ---

Please first localize the bug based on the issue statement, and then generate a patch according to the `git diff` format fenced by three backticks.

Here is an example:

```diff
diff --git a/examples/server_async.py b/examples/server_async.py
--- a/examples/server_async.py
+++ b/examples/server_async.py
@@ -313,4 +313,4 @@
 
 
 if __name__ == "__main__":
-    asyncio.run(run_async_server("."), debug=True)
+    asyncio.run(run_async_server(), debug=True)
diff --git a/examples/server_sync.py b/examples/server_sync.py
--- a/examples/server_sync.py
+++ b/examples/server_sync.py
@@ -313,5 +313,5 @@
 
 
 if __name__ == "__main__":
-    server = run_sync_server(".")
+    server = run_sync_server()
     server.shutdown()

```

diff --git a/securedrop/source_app/main.py b/securedrop/source_app/main.py
--- a/securedrop/source_app/main.py
+++ b/securedrop/source_app/main.py
@@ -188,6 +188,11 @@
         journalist_filename = g.source.journalist_filename
         first_submission = g.source.interaction_count == 0
 
+        if not os.path.exists(current_app.storage.path(g.filesystem_id)):
+            current_app.logger.debug("Store directory not found for source '{}', creating one."
+                                     .format(g.source.journalist_designation))
+            os.mkdir(current_app.storage.path(g.filesystem_id))
+
         if msg:
             g.source.interaction_count += 1
             fnames.append(

{"golden_diff": "diff --git a/securedrop/source_app/main.py b/securedrop/source_app/main.py\n--- a/securedrop/source_app/main.py\n+++ b/securedrop/source_app/main.py\n@@ -188,6 +188,11 @@\n         journalist_filename = g.source.journalist_filename\n         first_submission = g.source.interaction_count == 0\n \n+        if not os.path.exists(current_app.storage.path(g.filesystem_id)):\n+            current_app.logger.debug(\"Store directory not found for source '{}', creating one.\"\n+                                     .format(g.source.journalist_designation))\n+            os.mkdir(current_app.storage.path(g.filesystem_id))\n+\n         if msg:\n             g.source.interaction_count += 1\n             fnames.append(\n", "issue": "If a source's store directory is missing, subsequent submission attempts will fail with a FileNotFoundError exception\n## Description\r\n\r\nAs per title.\r\n\r\n## Steps to Reproduce\r\n\r\n(in dev)\r\n- log in to the SI and submit.\r\n- in a shell on the dev docker container (`docker exec -it securedrop-dev-0 bash`) find and delete the source's directory under `/var/lib/securedrop/store/` \r\n- submit from the SI again as the same source \r\n\r\n## Expected Behavior\r\nEither:\r\n- submission succeeds and directory is recreated,\r\nOr:\r\n- Submission fails with an informative error\r\n\r\n## Actual Behavior\r\nuncaught FileNotFoundError and a stack trace (in dev)\r\n\r\n\r\n\r\nSuggestions to fix, any other relevant information.\r\n\n", "before_files": [{"content": "import operator\nimport os\nimport io\n\nfrom base64 import urlsafe_b64encode\nfrom datetime import datetime\nfrom typing import Union\n\nimport werkzeug\nfrom flask import (Blueprint, render_template, flash, redirect, url_for, g,\n                   session, current_app, request, Markup, abort)\nfrom flask_babel import gettext\nfrom sqlalchemy.exc import IntegrityError\n\nimport store\n\nfrom db import db\nfrom models import Source, Submission, Reply, get_one_or_else\nfrom sdconfig import SDConfig\nfrom source_app.decorators import login_required\nfrom source_app.utils import (logged_in, generate_unique_codename,\n                              async_genkey, normalize_timestamps,\n                              valid_codename, get_entropy_estimate)\nfrom source_app.forms import LoginForm, SubmissionForm\n\n\ndef make_blueprint(config: SDConfig) -> Blueprint:\n    view = Blueprint('main', __name__)\n\n    @view.route('/')\n    def index() -> str:\n        return render_template('index.html')\n\n    @view.route('/generate', methods=('GET', 'POST'))\n    def generate() -> Union[str, werkzeug.Response]:\n        if logged_in():\n            flash(gettext(\n                \"You were redirected because you are already logged in. \"\n                \"If you want to create a new account, you should log out \"\n                \"first.\"),\n                  \"notification\")\n            return redirect(url_for('.lookup'))\n\n        codename = generate_unique_codename(config)\n\n        # Generate a unique id for each browser tab and associate the codename with this id.\n        # This will allow retrieval of the codename displayed in the tab from which the source has\n        # clicked to proceed to /generate (ref. issue #4458)\n        tab_id = urlsafe_b64encode(os.urandom(64)).decode()\n        codenames = session.get('codenames', {})\n        codenames[tab_id] = codename\n        session['codenames'] = codenames\n\n        session['new_user'] = True\n        return render_template('generate.html', codename=codename, tab_id=tab_id)\n\n    @view.route('/create', methods=['POST'])\n    def create() -> werkzeug.Response:\n        if session.get('logged_in', False):\n            flash(gettext(\"You are already logged in. Please verify your codename above as it \" +\n                          \"may differ from the one displayed on the previous page.\"),\n                  'notification')\n        else:\n            tab_id = request.form['tab_id']\n            codename = session['codenames'][tab_id]\n            session['codename'] = codename\n\n            del session['codenames']\n\n            filesystem_id = current_app.crypto_util.hash_codename(codename)\n            try:\n                source = Source(filesystem_id, current_app.crypto_util.display_id())\n            except ValueError as e:\n                current_app.logger.error(e)\n                flash(\n                    gettext(\"There was a temporary problem creating your account. \"\n                            \"Please try again.\"\n                            ),\n                    'error'\n                )\n                return redirect(url_for('.index'))\n\n            db.session.add(source)\n            try:\n                db.session.commit()\n            except IntegrityError as e:\n                db.session.rollback()\n                current_app.logger.error(\n                    \"Attempt to create a source with duplicate codename: %s\" %\n                    (e,))\n\n                # Issue 2386: don't log in on duplicates\n                del session['codename']\n\n                # Issue 4361: Delete 'logged_in' if it's in the session\n                try:\n                    del session['logged_in']\n                except KeyError:\n                    pass\n\n                abort(500)\n            else:\n                os.mkdir(current_app.storage.path(filesystem_id))\n\n            session['logged_in'] = True\n        return redirect(url_for('.lookup'))\n\n    @view.route('/lookup', methods=('GET',))\n    @login_required\n    def lookup() -> str:\n        replies = []\n        source_inbox = Reply.query.filter(Reply.source_id == g.source.id) \\\n                                  .filter(Reply.deleted_by_source == False).all()  # noqa\n\n        for reply in source_inbox:\n            reply_path = current_app.storage.path(\n                g.filesystem_id,\n                reply.filename,\n            )\n            try:\n                with io.open(reply_path, \"rb\") as f:\n                    contents = f.read()\n                reply_obj = current_app.crypto_util.decrypt(g.codename, contents)\n                reply.decrypted = reply_obj\n            except UnicodeDecodeError:\n                current_app.logger.error(\"Could not decode reply %s\" %\n                                         reply.filename)\n            except FileNotFoundError:\n                current_app.logger.error(\"Reply file missing: %s\" %\n                                         reply.filename)\n            else:\n                reply.date = datetime.utcfromtimestamp(\n                    os.stat(reply_path).st_mtime)\n                replies.append(reply)\n\n        # Sort the replies by date\n        replies.sort(key=operator.attrgetter('date'), reverse=True)\n\n        # Generate a keypair to encrypt replies from the journalist\n        # Only do this if the journalist has flagged the source as one\n        # that they would like to reply to. (Issue #140.)\n        if not current_app.crypto_util.get_fingerprint(g.filesystem_id) and \\\n                g.source.flagged:\n            db_uri = current_app.config['SQLALCHEMY_DATABASE_URI']\n            async_genkey(current_app.crypto_util,\n                         db_uri,\n                         g.filesystem_id,\n                         g.codename)\n\n        return render_template(\n            'lookup.html',\n            allow_document_uploads=current_app.instance_config.allow_document_uploads,\n            codename=g.codename,\n            replies=replies,\n            flagged=g.source.flagged,\n            new_user=session.get('new_user', None),\n            haskey=current_app.crypto_util.get_fingerprint(g.filesystem_id),\n            form=SubmissionForm(),\n        )\n\n    @view.route('/submit', methods=('POST',))\n    @login_required\n    def submit() -> werkzeug.Response:\n        allow_document_uploads = current_app.instance_config.allow_document_uploads\n        form = SubmissionForm()\n        if not form.validate():\n            for field, errors in form.errors.items():\n                for error in errors:\n                    flash(error, \"error\")\n            return redirect(url_for('main.lookup'))\n\n        msg = request.form['msg']\n        fh = None\n        if allow_document_uploads and 'fh' in request.files:\n            fh = request.files['fh']\n\n        # Don't submit anything if it was an \"empty\" submission. #878\n        if not (msg or fh):\n            if allow_document_uploads:\n                flash(gettext(\n                    \"You must enter a message or choose a file to submit.\"),\n                      \"error\")\n            else:\n                flash(gettext(\"You must enter a message.\"), \"error\")\n            return redirect(url_for('main.lookup'))\n\n        fnames = []\n        journalist_filename = g.source.journalist_filename\n        first_submission = g.source.interaction_count == 0\n\n        if msg:\n            g.source.interaction_count += 1\n            fnames.append(\n                current_app.storage.save_message_submission(\n                    g.filesystem_id,\n                    g.source.interaction_count,\n                    journalist_filename,\n                    msg))\n        if fh:\n            g.source.interaction_count += 1\n            fnames.append(\n                current_app.storage.save_file_submission(\n                    g.filesystem_id,\n                    g.source.interaction_count,\n                    journalist_filename,\n                    fh.filename,\n                    fh.stream))\n\n        if first_submission:\n            flash_message = render_template('first_submission_flashed_message.html')\n            flash(Markup(flash_message), \"success\")\n\n        else:\n            if msg and not fh:\n                html_contents = gettext('Thanks! We received your message.')\n            elif fh and not msg:\n                html_contents = gettext('Thanks! We received your document.')\n            else:\n                html_contents = gettext('Thanks! We received your message and '\n                                        'document.')\n\n            flash_message = render_template(\n                'next_submission_flashed_message.html',\n                html_contents=html_contents\n            )\n            flash(Markup(flash_message), \"success\")\n\n        new_submissions = []\n        for fname in fnames:\n            submission = Submission(g.source, fname)\n            db.session.add(submission)\n            new_submissions.append(submission)\n\n        if g.source.pending:\n            g.source.pending = False\n\n            # Generate a keypair now, if there's enough entropy (issue #303)\n            # (gpg reads 300 bytes from /dev/random)\n            entropy_avail = get_entropy_estimate()\n            if entropy_avail >= 2400:\n                db_uri = current_app.config['SQLALCHEMY_DATABASE_URI']\n\n                async_genkey(current_app.crypto_util,\n                             db_uri,\n                             g.filesystem_id,\n                             g.codename)\n                current_app.logger.info(\"generating key, entropy: {}\".format(\n                    entropy_avail))\n            else:\n                current_app.logger.warning(\n                        \"skipping key generation. entropy: {}\".format(\n                                entropy_avail))\n\n        g.source.last_updated = datetime.utcnow()\n        db.session.commit()\n\n        for sub in new_submissions:\n            store.async_add_checksum_for_file(sub)\n\n        normalize_timestamps(g.filesystem_id)\n\n        return redirect(url_for('main.lookup'))\n\n    @view.route('/delete', methods=('POST',))\n    @login_required\n    def delete() -> werkzeug.Response:\n        \"\"\"This deletes the reply from the source's inbox, but preserves\n        the history for journalists such that they can view conversation\n        history.\n        \"\"\"\n\n        query = Reply.query.filter_by(\n            filename=request.form['reply_filename'],\n            source_id=g.source.id)\n        reply = get_one_or_else(query, current_app.logger, abort)\n        reply.deleted_by_source = True\n        db.session.add(reply)\n        db.session.commit()\n\n        flash(gettext(\"Reply deleted\"), \"notification\")\n        return redirect(url_for('.lookup'))\n\n    @view.route('/delete-all', methods=('POST',))\n    @login_required\n    def batch_delete() -> werkzeug.Response:\n        replies = Reply.query.filter(Reply.source_id == g.source.id) \\\n                             .filter(Reply.deleted_by_source == False).all()  # noqa\n        if len(replies) == 0:\n            current_app.logger.error(\"Found no replies when at least one was \"\n                                     \"expected\")\n            return redirect(url_for('.lookup'))\n\n        for reply in replies:\n            reply.deleted_by_source = True\n            db.session.add(reply)\n        db.session.commit()\n\n        flash(gettext(\"All replies have been deleted\"), \"notification\")\n        return redirect(url_for('.lookup'))\n\n    @view.route('/login', methods=('GET', 'POST'))\n    def login() -> Union[str, werkzeug.Response]:\n        form = LoginForm()\n        if form.validate_on_submit():\n            codename = request.form['codename'].strip()\n            if valid_codename(codename):\n                session.update(codename=codename, logged_in=True)\n                return redirect(url_for('.lookup', from_login='1'))\n            else:\n                current_app.logger.info(\n                        \"Login failed for invalid codename\")\n                flash(gettext(\"Sorry, that is not a recognized codename.\"),\n                      \"error\")\n        return render_template('login.html', form=form)\n\n    @view.route('/logout')\n    def logout() -> Union[str, werkzeug.Response]:\n        \"\"\"\n        If a user is logged in, show them a logout page that prompts them to\n        click the New Identity button in Tor Browser to complete their session.\n        Otherwise redirect to the main Source Interface page.\n        \"\"\"\n        if logged_in():\n\n            # Clear the session after we render the message so it's localized\n            # If a user specified a locale, save it and restore it\n            session.clear()\n            session['locale'] = g.localeinfo.id\n\n            return render_template('logout.html')\n        else:\n            return redirect(url_for('.index'))\n\n    return view\n", "path": "securedrop/source_app/main.py"}], "after_files": [{"content": "import operator\nimport os\nimport io\n\nfrom base64 import urlsafe_b64encode\nfrom datetime import datetime\nfrom typing import Union\n\nimport werkzeug\nfrom flask import (Blueprint, render_template, flash, redirect, url_for, g,\n                   session, current_app, request, Markup, abort)\nfrom flask_babel import gettext\nfrom sqlalchemy.exc import IntegrityError\n\nimport store\n\nfrom db import db\nfrom models import Source, Submission, Reply, get_one_or_else\nfrom sdconfig import SDConfig\nfrom source_app.decorators import login_required\nfrom source_app.utils import (logged_in, generate_unique_codename,\n                              async_genkey, normalize_timestamps,\n                              valid_codename, get_entropy_estimate)\nfrom source_app.forms import LoginForm, SubmissionForm\n\n\ndef make_blueprint(config: SDConfig) -> Blueprint:\n    view = Blueprint('main', __name__)\n\n    @view.route('/')\n    def index() -> str:\n        return render_template('index.html')\n\n    @view.route('/generate', methods=('GET', 'POST'))\n    def generate() -> Union[str, werkzeug.Response]:\n        if logged_in():\n            flash(gettext(\n                \"You were redirected because you are already logged in. \"\n                \"If you want to create a new account, you should log out \"\n                \"first.\"),\n                  \"notification\")\n            return redirect(url_for('.lookup'))\n\n        codename = generate_unique_codename(config)\n\n        # Generate a unique id for each browser tab and associate the codename with this id.\n        # This will allow retrieval of the codename displayed in the tab from which the source has\n        # clicked to proceed to /generate (ref. issue #4458)\n        tab_id = urlsafe_b64encode(os.urandom(64)).decode()\n        codenames = session.get('codenames', {})\n        codenames[tab_id] = codename\n        session['codenames'] = codenames\n\n        session['new_user'] = True\n        return render_template('generate.html', codename=codename, tab_id=tab_id)\n\n    @view.route('/create', methods=['POST'])\n    def create() -> werkzeug.Response:\n        if session.get('logged_in', False):\n            flash(gettext(\"You are already logged in. Please verify your codename above as it \" +\n                          \"may differ from the one displayed on the previous page.\"),\n                  'notification')\n        else:\n            tab_id = request.form['tab_id']\n            codename = session['codenames'][tab_id]\n            session['codename'] = codename\n\n            del session['codenames']\n\n            filesystem_id = current_app.crypto_util.hash_codename(codename)\n            try:\n                source = Source(filesystem_id, current_app.crypto_util.display_id())\n            except ValueError as e:\n                current_app.logger.error(e)\n                flash(\n                    gettext(\"There was a temporary problem creating your account. \"\n                            \"Please try again.\"\n                            ),\n                    'error'\n                )\n                return redirect(url_for('.index'))\n\n            db.session.add(source)\n            try:\n                db.session.commit()\n            except IntegrityError as e:\n                db.session.rollback()\n                current_app.logger.error(\n                    \"Attempt to create a source with duplicate codename: %s\" %\n                    (e,))\n\n                # Issue 2386: don't log in on duplicates\n                del session['codename']\n\n                # Issue 4361: Delete 'logged_in' if it's in the session\n                try:\n                    del session['logged_in']\n                except KeyError:\n                    pass\n\n                abort(500)\n            else:\n                os.mkdir(current_app.storage.path(filesystem_id))\n\n            session['logged_in'] = True\n        return redirect(url_for('.lookup'))\n\n    @view.route('/lookup', methods=('GET',))\n    @login_required\n    def lookup() -> str:\n        replies = []\n        source_inbox = Reply.query.filter(Reply.source_id == g.source.id) \\\n                                  .filter(Reply.deleted_by_source == False).all()  # noqa\n\n        for reply in source_inbox:\n            reply_path = current_app.storage.path(\n                g.filesystem_id,\n                reply.filename,\n            )\n            try:\n                with io.open(reply_path, \"rb\") as f:\n                    contents = f.read()\n                reply_obj = current_app.crypto_util.decrypt(g.codename, contents)\n                reply.decrypted = reply_obj\n            except UnicodeDecodeError:\n                current_app.logger.error(\"Could not decode reply %s\" %\n                                         reply.filename)\n            except FileNotFoundError:\n                current_app.logger.error(\"Reply file missing: %s\" %\n                                         reply.filename)\n            else:\n                reply.date = datetime.utcfromtimestamp(\n                    os.stat(reply_path).st_mtime)\n                replies.append(reply)\n\n        # Sort the replies by date\n        replies.sort(key=operator.attrgetter('date'), reverse=True)\n\n        # Generate a keypair to encrypt replies from the journalist\n        # Only do this if the journalist has flagged the source as one\n        # that they would like to reply to. (Issue #140.)\n        if not current_app.crypto_util.get_fingerprint(g.filesystem_id) and \\\n                g.source.flagged:\n            db_uri = current_app.config['SQLALCHEMY_DATABASE_URI']\n            async_genkey(current_app.crypto_util,\n                         db_uri,\n                         g.filesystem_id,\n                         g.codename)\n\n        return render_template(\n            'lookup.html',\n            allow_document_uploads=current_app.instance_config.allow_document_uploads,\n            codename=g.codename,\n            replies=replies,\n            flagged=g.source.flagged,\n            new_user=session.get('new_user', None),\n            haskey=current_app.crypto_util.get_fingerprint(g.filesystem_id),\n            form=SubmissionForm(),\n        )\n\n    @view.route('/submit', methods=('POST',))\n    @login_required\n    def submit() -> werkzeug.Response:\n        allow_document_uploads = current_app.instance_config.allow_document_uploads\n        form = SubmissionForm()\n        if not form.validate():\n            for field, errors in form.errors.items():\n                for error in errors:\n                    flash(error, \"error\")\n            return redirect(url_for('main.lookup'))\n\n        msg = request.form['msg']\n        fh = None\n        if allow_document_uploads and 'fh' in request.files:\n            fh = request.files['fh']\n\n        # Don't submit anything if it was an \"empty\" submission. #878\n        if not (msg or fh):\n            if allow_document_uploads:\n                flash(gettext(\n                    \"You must enter a message or choose a file to submit.\"),\n                      \"error\")\n            else:\n                flash(gettext(\"You must enter a message.\"), \"error\")\n            return redirect(url_for('main.lookup'))\n\n        fnames = []\n        journalist_filename = g.source.journalist_filename\n        first_submission = g.source.interaction_count == 0\n\n        if not os.path.exists(current_app.storage.path(g.filesystem_id)):\n            current_app.logger.debug(\"Store directory not found for source '{}', creating one.\"\n                                     .format(g.source.journalist_designation))\n            os.mkdir(current_app.storage.path(g.filesystem_id))\n\n        if msg:\n            g.source.interaction_count += 1\n            fnames.append(\n                current_app.storage.save_message_submission(\n                    g.filesystem_id,\n                    g.source.interaction_count,\n                    journalist_filename,\n                    msg))\n        if fh:\n            g.source.interaction_count += 1\n            fnames.append(\n                current_app.storage.save_file_submission(\n                    g.filesystem_id,\n                    g.source.interaction_count,\n                    journalist_filename,\n                    fh.filename,\n                    fh.stream))\n\n        if first_submission:\n            flash_message = render_template('first_submission_flashed_message.html')\n            flash(Markup(flash_message), \"success\")\n\n        else:\n            if msg and not fh:\n                html_contents = gettext('Thanks! We received your message.')\n            elif fh and not msg:\n                html_contents = gettext('Thanks! We received your document.')\n            else:\n                html_contents = gettext('Thanks! We received your message and '\n                                        'document.')\n\n            flash_message = render_template(\n                'next_submission_flashed_message.html',\n                html_contents=html_contents\n            )\n            flash(Markup(flash_message), \"success\")\n\n        new_submissions = []\n        for fname in fnames:\n            submission = Submission(g.source, fname)\n            db.session.add(submission)\n            new_submissions.append(submission)\n\n        if g.source.pending:\n            g.source.pending = False\n\n            # Generate a keypair now, if there's enough entropy (issue #303)\n            # (gpg reads 300 bytes from /dev/random)\n            entropy_avail = get_entropy_estimate()\n            if entropy_avail >= 2400:\n                db_uri = current_app.config['SQLALCHEMY_DATABASE_URI']\n\n                async_genkey(current_app.crypto_util,\n                             db_uri,\n                             g.filesystem_id,\n                             g.codename)\n                current_app.logger.info(\"generating key, entropy: {}\".format(\n                    entropy_avail))\n            else:\n                current_app.logger.warning(\n                        \"skipping key generation. entropy: {}\".format(\n                                entropy_avail))\n\n        g.source.last_updated = datetime.utcnow()\n        db.session.commit()\n\n        for sub in new_submissions:\n            store.async_add_checksum_for_file(sub)\n\n        normalize_timestamps(g.filesystem_id)\n\n        return redirect(url_for('main.lookup'))\n\n    @view.route('/delete', methods=('POST',))\n    @login_required\n    def delete() -> werkzeug.Response:\n        \"\"\"This deletes the reply from the source's inbox, but preserves\n        the history for journalists such that they can view conversation\n        history.\n        \"\"\"\n\n        query = Reply.query.filter_by(\n            filename=request.form['reply_filename'],\n            source_id=g.source.id)\n        reply = get_one_or_else(query, current_app.logger, abort)\n        reply.deleted_by_source = True\n        db.session.add(reply)\n        db.session.commit()\n\n        flash(gettext(\"Reply deleted\"), \"notification\")\n        return redirect(url_for('.lookup'))\n\n    @view.route('/delete-all', methods=('POST',))\n    @login_required\n    def batch_delete() -> werkzeug.Response:\n        replies = Reply.query.filter(Reply.source_id == g.source.id) \\\n                             .filter(Reply.deleted_by_source == False).all()  # noqa\n        if len(replies) == 0:\n            current_app.logger.error(\"Found no replies when at least one was \"\n                                     \"expected\")\n            return redirect(url_for('.lookup'))\n\n        for reply in replies:\n            reply.deleted_by_source = True\n            db.session.add(reply)\n        db.session.commit()\n\n        flash(gettext(\"All replies have been deleted\"), \"notification\")\n        return redirect(url_for('.lookup'))\n\n    @view.route('/login', methods=('GET', 'POST'))\n    def login() -> Union[str, werkzeug.Response]:\n        form = LoginForm()\n        if form.validate_on_submit():\n            codename = request.form['codename'].strip()\n            if valid_codename(codename):\n                session.update(codename=codename, logged_in=True)\n                return redirect(url_for('.lookup', from_login='1'))\n            else:\n                current_app.logger.info(\n                        \"Login failed for invalid codename\")\n                flash(gettext(\"Sorry, that is not a recognized codename.\"),\n                      \"error\")\n        return render_template('login.html', form=form)\n\n    @view.route('/logout')\n    def logout() -> Union[str, werkzeug.Response]:\n        \"\"\"\n        If a user is logged in, show them a logout page that prompts them to\n        click the New Identity button in Tor Browser to complete their session.\n        Otherwise redirect to the main Source Interface page.\n        \"\"\"\n        if logged_in():\n\n            # Clear the session after we render the message so it's localized\n            # If a user specified a locale, save it and restore it\n            session.clear()\n            session['locale'] = g.localeinfo.id\n\n            return render_template('logout.html')\n        else:\n            return redirect(url_for('.index'))\n\n    return view\n", "path": "securedrop/source_app/main.py"}]}