We are currently solving the following issue within our repository. Here is the issue text:
--- BEGIN ISSUE ---
Update UI/docs consistent with "New Identity" behavior change in Tor Browser 9.0
The "New Identity" behavior has changed once again in Tor Browser 9.0. There's now a dedicated button for it:
![New identity button](https://blog.torproject.org/sites/default/files/inline-images/toolbar%20update.png)

So we have to update these instructions:

> Thank you for exiting your session! Please select "New Identity" from the onion button in the Tor browser's toolbar to clear all history of your SecureDrop usage from this device.

And also the relevant portion in the [source guide](https://docs.securedrop.org/en/release-1.1.0/source.html).

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

{"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@@ -296,8 +296,12 @@\n \n     @view.route('/logout')\n     def logout():\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-            msg = render_template('logout_flashed_message.html')\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@@ -305,7 +309,8 @@\n             session.clear()\n             session['locale'] = user_locale\n \n-            flash(Markup(msg), \"important hide-if-not-tor-browser\")\n-        return redirect(url_for('.index'))\n+            return render_template('logout.html')\n+        else:\n+            return redirect(url_for('.index'))\n \n     return view\n", "issue": "Update UI/docs consistent with \"New Identity\" behavior change in Tor Browser 9.0\nThe \"New Identity\" behavior has changed once again in Tor Browser 9.0. There's now a dedicated button for it:\r\n![New identity button](https://blog.torproject.org/sites/default/files/inline-images/toolbar%20update.png)\r\n\r\nSo we have to update these instructions:\r\n\r\n> Thank you for exiting your session! Please select \"New Identity\" from the onion button in the Tor browser's toolbar to clear all history of your SecureDrop usage from this device.\r\n\r\nAnd also the relevant portion in the [source guide](https://docs.securedrop.org/en/release-1.1.0/source.html).\n", "before_files": [{"content": "import operator\nimport os\nimport io\n\nfrom base64 import urlsafe_b64encode\nfrom datetime import datetime\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 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\n\n\ndef make_blueprint(config):\n    view = Blueprint('main', __name__)\n\n    @view.route('/')\n    def index():\n        return render_template('index.html')\n\n    @view.route('/generate', methods=('GET', 'POST'))\n    def generate():\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('/org-logo')\n    def select_logo():\n        if os.path.exists(os.path.join(current_app.static_folder, 'i',\n                          'custom_logo.png')):\n            return redirect(url_for('static', filename='i/custom_logo.png'))\n        else:\n            return redirect(url_for('static', filename='i/logo.png'))\n\n    @view.route('/create', methods=['POST'])\n    def create():\n        if session.get('logged_in', False):\n            flash(gettext(\"You are already logged in. Please verify your codename below 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\n            source = Source(filesystem_id, current_app.crypto_util.display_id())\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():\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            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(\n                g.filesystem_id))\n\n    @view.route('/submit', methods=('POST',))\n    @login_required\n    def submit():\n        allow_document_uploads = current_app.instance_config.allow_document_uploads\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            msg = render_template('first_submission_flashed_message.html')\n            flash(Markup(msg), \"success\")\n\n        else:\n            if msg and not fh:\n                html_contents = gettext('Thanks! We received your message.')\n            elif not msg and fh:\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            msg = render_template('next_submission_flashed_message.html',\n                                  html_contents=html_contents)\n            flash(Markup(msg), \"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.warn(\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():\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():\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():\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():\n        if logged_in():\n            msg = render_template('logout_flashed_message.html')\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            user_locale = g.locale\n            session.clear()\n            session['locale'] = user_locale\n\n            flash(Markup(msg), \"important hide-if-not-tor-browser\")\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 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 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\n\n\ndef make_blueprint(config):\n    view = Blueprint('main', __name__)\n\n    @view.route('/')\n    def index():\n        return render_template('index.html')\n\n    @view.route('/generate', methods=('GET', 'POST'))\n    def generate():\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('/org-logo')\n    def select_logo():\n        if os.path.exists(os.path.join(current_app.static_folder, 'i',\n                          'custom_logo.png')):\n            return redirect(url_for('static', filename='i/custom_logo.png'))\n        else:\n            return redirect(url_for('static', filename='i/logo.png'))\n\n    @view.route('/create', methods=['POST'])\n    def create():\n        if session.get('logged_in', False):\n            flash(gettext(\"You are already logged in. Please verify your codename below 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\n            source = Source(filesystem_id, current_app.crypto_util.display_id())\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():\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            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(\n                g.filesystem_id))\n\n    @view.route('/submit', methods=('POST',))\n    @login_required\n    def submit():\n        allow_document_uploads = current_app.instance_config.allow_document_uploads\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            msg = render_template('first_submission_flashed_message.html')\n            flash(Markup(msg), \"success\")\n\n        else:\n            if msg and not fh:\n                html_contents = gettext('Thanks! We received your message.')\n            elif not msg and fh:\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            msg = render_template('next_submission_flashed_message.html',\n                                  html_contents=html_contents)\n            flash(Markup(msg), \"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.warn(\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():\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():\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():\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():\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            user_locale = g.locale\n            session.clear()\n            session['locale'] = user_locale\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"}]}