We are currently solving the following issue within our repository. Here is the issue text:
--- BEGIN ISSUE ---
investigate and implement improvements to header-based tor2web detection for current active tor2web proxies


--- 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, timedelta, timezone
7 from typing import Union
8 
9 import werkzeug
10 from flask import (Blueprint, render_template, flash, redirect, url_for,
11                    session, current_app, request, Markup, abort, g, make_response)
12 from flask_babel import gettext
13 
14 import store
15 
16 from store import Storage
17 
18 from db import db
19 from encryption import EncryptionManager, GpgKeyNotFoundError
20 
21 from models import Submission, Reply, get_one_or_else, InstanceConfig
22 from passphrases import PassphraseGenerator, DicewarePassphrase
23 from sdconfig import SDConfig
24 from source_app.decorators import login_required
25 from source_app.session_manager import SessionManager
26 from source_app.utils import normalize_timestamps, fit_codenames_into_cookie, \
27     clear_session_and_redirect_to_logged_out_page
28 from source_app.forms import LoginForm, SubmissionForm
29 from source_user import InvalidPassphraseError, create_source_user, \
30     SourcePassphraseCollisionError, SourceDesignationCollisionError, SourceUser
31 
32 
33 def make_blueprint(config: SDConfig) -> Blueprint:
34     view = Blueprint('main', __name__)
35 
36     @view.route('/')
37     def index() -> str:
38         return render_template('index.html')
39 
40     @view.route('/generate', methods=('GET',))
41     def generate() -> Union[str, werkzeug.Response]:
42         if SessionManager.is_user_logged_in(db_session=db.session):
43             flash(gettext(
44                 "You were redirected because you are already logged in. "
45                 "If you want to create a new account, you should log out "
46                 "first."),
47                   "notification")
48             return redirect(url_for('.lookup'))
49 
50         codename = PassphraseGenerator.get_default().generate_passphrase(
51             preferred_language=g.localeinfo.language
52         )
53 
54         # Generate a unique id for each browser tab and associate the codename with this id.
55         # This will allow retrieval of the codename displayed in the tab from which the source has
56         # clicked to proceed to /generate (ref. issue #4458)
57         tab_id = urlsafe_b64encode(os.urandom(64)).decode()
58         codenames = session.get('codenames', {})
59         codenames[tab_id] = codename
60         session['codenames'] = fit_codenames_into_cookie(codenames)
61         session["codenames_expire"] = datetime.now(timezone.utc) + timedelta(
62             minutes=config.SESSION_EXPIRATION_MINUTES
63         )
64         return render_template('generate.html', codename=codename, tab_id=tab_id)
65 
66     @view.route('/create', methods=['POST'])
67     def create() -> werkzeug.Response:
68         if SessionManager.is_user_logged_in(db_session=db.session):
69             flash(gettext("You are already logged in. Please verify your codename above as it " +
70                           "may differ from the one displayed on the previous page."),
71                   'notification')
72         else:
73             # Ensure the codenames have not expired
74             date_codenames_expire = session.get("codenames_expire")
75             if not date_codenames_expire or datetime.now(timezone.utc) >= date_codenames_expire:
76                 return clear_session_and_redirect_to_logged_out_page(flask_session=session)
77 
78             tab_id = request.form['tab_id']
79             codename = session['codenames'][tab_id]
80             del session['codenames']
81 
82             try:
83                 current_app.logger.info("Creating new source user...")
84                 create_source_user(
85                     db_session=db.session,
86                     source_passphrase=codename,
87                     source_app_storage=Storage.get_default(),
88                 )
89             except (SourcePassphraseCollisionError, SourceDesignationCollisionError) as e:
90                 current_app.logger.error("Could not create a source: {}".format(e))
91                 flash(
92                     gettext(
93                         "There was a temporary problem creating your account. Please try again."
94                     ),
95                     "error"
96                 )
97                 return redirect(url_for('.index'))
98 
99             # All done - source user was successfully created
100             current_app.logger.info("New source user created")
101             session['new_user_codename'] = codename
102             SessionManager.log_user_in(db_session=db.session,
103                                        supplied_passphrase=DicewarePassphrase(codename))
104 
105         return redirect(url_for('.lookup'))
106 
107     @view.route('/lookup', methods=('GET',))
108     @login_required
109     def lookup(logged_in_source: SourceUser) -> str:
110         replies = []
111         logged_in_source_in_db = logged_in_source.get_db_record()
112         source_inbox = Reply.query.filter_by(
113             source_id=logged_in_source_in_db.id, deleted_by_source=False
114         ).all()
115 
116         for reply in source_inbox:
117             reply_path = Storage.get_default().path(
118                 logged_in_source.filesystem_id,
119                 reply.filename,
120             )
121             try:
122                 with io.open(reply_path, "rb") as f:
123                     contents = f.read()
124                 decrypted_reply = EncryptionManager.get_default().decrypt_journalist_reply(
125                     for_source_user=logged_in_source,
126                     ciphertext_in=contents
127                 )
128                 reply.decrypted = decrypted_reply
129             except UnicodeDecodeError:
130                 current_app.logger.error("Could not decode reply %s" %
131                                          reply.filename)
132             except FileNotFoundError:
133                 current_app.logger.error("Reply file missing: %s" %
134                                          reply.filename)
135             else:
136                 reply.date = datetime.utcfromtimestamp(
137                     os.stat(reply_path).st_mtime)
138                 replies.append(reply)
139 
140         # Sort the replies by date
141         replies.sort(key=operator.attrgetter('date'), reverse=True)
142 
143         # If not done yet, generate a keypair to encrypt replies from the journalist
144         encryption_mgr = EncryptionManager.get_default()
145         try:
146             encryption_mgr.get_source_public_key(logged_in_source.filesystem_id)
147         except GpgKeyNotFoundError:
148             encryption_mgr.generate_source_key_pair(logged_in_source)
149 
150         return render_template(
151             'lookup.html',
152             is_user_logged_in=True,
153             allow_document_uploads=InstanceConfig.get_default().allow_document_uploads,
154             replies=replies,
155             new_user_codename=session.get('new_user_codename', None),
156             form=SubmissionForm(),
157         )
158 
159     @view.route('/submit', methods=('POST',))
160     @login_required
161     def submit(logged_in_source: SourceUser) -> werkzeug.Response:
162         allow_document_uploads = InstanceConfig.get_default().allow_document_uploads
163         form = SubmissionForm()
164         if not form.validate():
165             for field, errors in form.errors.items():
166                 for error in errors:
167                     flash(error, "error")
168             return redirect(f"{url_for('main.lookup')}#flashed")
169 
170         msg = request.form['msg']
171         fh = None
172         if allow_document_uploads and 'fh' in request.files:
173             fh = request.files['fh']
174 
175         # Don't submit anything if it was an "empty" submission. #878
176         if not (msg or fh):
177             if allow_document_uploads:
178                 flash(gettext(
179                     "You must enter a message or choose a file to submit."),
180                       "error")
181             else:
182                 flash(gettext("You must enter a message."), "error")
183             return redirect(f"{url_for('main.lookup')}#flashed")
184 
185         fnames = []
186         logged_in_source_in_db = logged_in_source.get_db_record()
187         first_submission = logged_in_source_in_db.interaction_count == 0
188 
189         if not os.path.exists(Storage.get_default().path(logged_in_source.filesystem_id)):
190             current_app.logger.debug("Store directory not found for source '{}', creating one."
191                                      .format(logged_in_source_in_db.journalist_designation))
192             os.mkdir(Storage.get_default().path(logged_in_source.filesystem_id))
193 
194         if msg:
195             logged_in_source_in_db.interaction_count += 1
196             fnames.append(
197                 Storage.get_default().save_message_submission(
198                     logged_in_source_in_db.filesystem_id,
199                     logged_in_source_in_db.interaction_count,
200                     logged_in_source_in_db.journalist_filename,
201                     msg))
202         if fh:
203             logged_in_source_in_db.interaction_count += 1
204             fnames.append(
205                 Storage.get_default().save_file_submission(
206                     logged_in_source_in_db.filesystem_id,
207                     logged_in_source_in_db.interaction_count,
208                     logged_in_source_in_db.journalist_filename,
209                     fh.filename,
210                     fh.stream))
211 
212         if first_submission:
213             flash_message = render_template(
214                 'first_submission_flashed_message.html',
215                 new_user_codename=session.get('new_user_codename', None),
216             )
217             flash(Markup(flash_message), "success")
218 
219         else:
220             if msg and not fh:
221                 html_contents = gettext('Thanks! We received your message.')
222             elif fh and not msg:
223                 html_contents = gettext('Thanks! We received your document.')
224             else:
225                 html_contents = gettext('Thanks! We received your message and '
226                                         'document.')
227 
228             flash_message = render_template(
229                 'next_submission_flashed_message.html',
230                 html_contents=html_contents
231             )
232             flash(Markup(flash_message), "success")
233 
234         new_submissions = []
235         for fname in fnames:
236             submission = Submission(logged_in_source_in_db, fname, Storage.get_default())
237             db.session.add(submission)
238             new_submissions.append(submission)
239 
240         logged_in_source_in_db.pending = False
241         logged_in_source_in_db.last_updated = datetime.now(timezone.utc)
242         db.session.commit()
243 
244         for sub in new_submissions:
245             store.async_add_checksum_for_file(sub, Storage.get_default())
246 
247         normalize_timestamps(logged_in_source)
248 
249         return redirect(f"{url_for('main.lookup')}#flashed")
250 
251     @view.route('/delete', methods=('POST',))
252     @login_required
253     def delete(logged_in_source: SourceUser) -> werkzeug.Response:
254         """This deletes the reply from the source's inbox, but preserves
255         the history for journalists such that they can view conversation
256         history.
257         """
258 
259         query = Reply.query.filter_by(
260             filename=request.form['reply_filename'],
261             source_id=logged_in_source.db_record_id)
262         reply = get_one_or_else(query, current_app.logger, abort)
263         reply.deleted_by_source = True
264         db.session.add(reply)
265         db.session.commit()
266 
267         flash(gettext("Reply deleted"), "notification")
268         return redirect(url_for('.lookup'))
269 
270     @view.route('/delete-all', methods=('POST',))
271     @login_required
272     def batch_delete(logged_in_source: SourceUser) -> werkzeug.Response:
273         replies = Reply.query.filter(Reply.source_id == logged_in_source.db_record_id) \
274                              .filter(Reply.deleted_by_source == False).all()  # noqa
275         if len(replies) == 0:
276             current_app.logger.error("Found no replies when at least one was "
277                                      "expected")
278             return redirect(url_for('.lookup'))
279 
280         for reply in replies:
281             reply.deleted_by_source = True
282             db.session.add(reply)
283         db.session.commit()
284 
285         flash(gettext("All replies have been deleted"), "notification")
286         return redirect(url_for('.lookup'))
287 
288     @view.route('/login', methods=('GET', 'POST'))
289     def login() -> Union[str, werkzeug.Response]:
290         form = LoginForm()
291         if form.validate_on_submit():
292             try:
293                 SessionManager.log_user_in(
294                     db_session=db.session,
295                     supplied_passphrase=DicewarePassphrase(request.form['codename'].strip())
296                 )
297             except InvalidPassphraseError:
298                 current_app.logger.info("Login failed for invalid codename")
299                 flash(gettext("Sorry, that is not a recognized codename."), "error")
300             else:
301                 # Success: a valid passphrase was supplied
302                 return redirect(url_for('.lookup', from_login='1'))
303 
304         return render_template('login.html', form=form)
305 
306     @view.route('/logout')
307     def logout() -> Union[str, werkzeug.Response]:
308         """
309         If a user is logged in, show them a logout page that prompts them to
310         click the New Identity button in Tor Browser to complete their session.
311         Otherwise redirect to the main Source Interface page.
312         """
313         if SessionManager.is_user_logged_in(db_session=db.session):
314             SessionManager.log_user_out()
315 
316             # Clear the session after we render the message so it's localized
317             # If a user specified a locale, save it and restore it
318             session.clear()
319             session['locale'] = g.localeinfo.id
320 
321             return render_template('logout.html')
322         else:
323             return redirect(url_for('.index'))
324 
325     @view.route('/robots.txt')
326     def robots_txt() -> werkzeug.Response:
327         """Tell robots we don't want them"""
328         resp = make_response("User-agent: *\nDisallow: /")
329         resp.headers["content-type"] = "text/plain"
330         return resp
331 
332     return view
333
```
--- 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
@@ -37,8 +37,15 @@
     def index() -> str:
         return render_template('index.html')
 
-    @view.route('/generate', methods=('GET',))
+    @view.route('/generate', methods=('POST',))
     def generate() -> Union[str, werkzeug.Response]:
+        # Try to detect Tor2Web usage by looking to see if tor2web_check got mangled
+        tor2web_check = request.form.get('tor2web_check')
+        if tor2web_check is None:
+            # Missing form field
+            abort(403)
+        elif tor2web_check != 'href="fake.onion"':
+            return redirect(url_for('info.tor2web_warning'))
         if SessionManager.is_user_logged_in(db_session=db.session):
             flash(gettext(
                 "You were redirected because you are already logged in. "
@@ -46,7 +53,6 @@
                 "first."),
                   "notification")
             return redirect(url_for('.lookup'))
-
         codename = PassphraseGenerator.get_default().generate_passphrase(
             preferred_language=g.localeinfo.language
         )

{"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@@ -37,8 +37,15 @@\n     def index() -> str:\n         return render_template('index.html')\n \n-    @view.route('/generate', methods=('GET',))\n+    @view.route('/generate', methods=('POST',))\n     def generate() -> Union[str, werkzeug.Response]:\n+        # Try to detect Tor2Web usage by looking to see if tor2web_check got mangled\n+        tor2web_check = request.form.get('tor2web_check')\n+        if tor2web_check is None:\n+            # Missing form field\n+            abort(403)\n+        elif tor2web_check != 'href=\"fake.onion\"':\n+            return redirect(url_for('info.tor2web_warning'))\n         if SessionManager.is_user_logged_in(db_session=db.session):\n             flash(gettext(\n                 \"You were redirected because you are already logged in. \"\n@@ -46,7 +53,6 @@\n                 \"first.\"),\n                   \"notification\")\n             return redirect(url_for('.lookup'))\n-\n         codename = PassphraseGenerator.get_default().generate_passphrase(\n             preferred_language=g.localeinfo.language\n         )\n", "issue": "investigate and implement improvements to header-based tor2web detection for current active tor2web proxies\n\n", "before_files": [{"content": "import operator\nimport os\nimport io\n\nfrom base64 import urlsafe_b64encode\nfrom datetime import datetime, timedelta, timezone\nfrom typing import Union\n\nimport werkzeug\nfrom flask import (Blueprint, render_template, flash, redirect, url_for,\n                   session, current_app, request, Markup, abort, g, make_response)\nfrom flask_babel import gettext\n\nimport store\n\nfrom store import Storage\n\nfrom db import db\nfrom encryption import EncryptionManager, GpgKeyNotFoundError\n\nfrom models import Submission, Reply, get_one_or_else, InstanceConfig\nfrom passphrases import PassphraseGenerator, DicewarePassphrase\nfrom sdconfig import SDConfig\nfrom source_app.decorators import login_required\nfrom source_app.session_manager import SessionManager\nfrom source_app.utils import normalize_timestamps, fit_codenames_into_cookie, \\\n    clear_session_and_redirect_to_logged_out_page\nfrom source_app.forms import LoginForm, SubmissionForm\nfrom source_user import InvalidPassphraseError, create_source_user, \\\n    SourcePassphraseCollisionError, SourceDesignationCollisionError, SourceUser\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',))\n    def generate() -> Union[str, werkzeug.Response]:\n        if SessionManager.is_user_logged_in(db_session=db.session):\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 = PassphraseGenerator.get_default().generate_passphrase(\n            preferred_language=g.localeinfo.language\n        )\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'] = fit_codenames_into_cookie(codenames)\n        session[\"codenames_expire\"] = datetime.now(timezone.utc) + timedelta(\n            minutes=config.SESSION_EXPIRATION_MINUTES\n        )\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 SessionManager.is_user_logged_in(db_session=db.session):\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            # Ensure the codenames have not expired\n            date_codenames_expire = session.get(\"codenames_expire\")\n            if not date_codenames_expire or datetime.now(timezone.utc) >= date_codenames_expire:\n                return clear_session_and_redirect_to_logged_out_page(flask_session=session)\n\n            tab_id = request.form['tab_id']\n            codename = session['codenames'][tab_id]\n            del session['codenames']\n\n            try:\n                current_app.logger.info(\"Creating new source user...\")\n                create_source_user(\n                    db_session=db.session,\n                    source_passphrase=codename,\n                    source_app_storage=Storage.get_default(),\n                )\n            except (SourcePassphraseCollisionError, SourceDesignationCollisionError) as e:\n                current_app.logger.error(\"Could not create a source: {}\".format(e))\n                flash(\n                    gettext(\n                        \"There was a temporary problem creating your account. Please try again.\"\n                    ),\n                    \"error\"\n                )\n                return redirect(url_for('.index'))\n\n            # All done - source user was successfully created\n            current_app.logger.info(\"New source user created\")\n            session['new_user_codename'] = codename\n            SessionManager.log_user_in(db_session=db.session,\n                                       supplied_passphrase=DicewarePassphrase(codename))\n\n        return redirect(url_for('.lookup'))\n\n    @view.route('/lookup', methods=('GET',))\n    @login_required\n    def lookup(logged_in_source: SourceUser) -> str:\n        replies = []\n        logged_in_source_in_db = logged_in_source.get_db_record()\n        source_inbox = Reply.query.filter_by(\n            source_id=logged_in_source_in_db.id, deleted_by_source=False\n        ).all()\n\n        for reply in source_inbox:\n            reply_path = Storage.get_default().path(\n                logged_in_source.filesystem_id,\n                reply.filename,\n            )\n            try:\n                with io.open(reply_path, \"rb\") as f:\n                    contents = f.read()\n                decrypted_reply = EncryptionManager.get_default().decrypt_journalist_reply(\n                    for_source_user=logged_in_source,\n                    ciphertext_in=contents\n                )\n                reply.decrypted = decrypted_reply\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        # If not done yet, generate a keypair to encrypt replies from the journalist\n        encryption_mgr = EncryptionManager.get_default()\n        try:\n            encryption_mgr.get_source_public_key(logged_in_source.filesystem_id)\n        except GpgKeyNotFoundError:\n            encryption_mgr.generate_source_key_pair(logged_in_source)\n\n        return render_template(\n            'lookup.html',\n            is_user_logged_in=True,\n            allow_document_uploads=InstanceConfig.get_default().allow_document_uploads,\n            replies=replies,\n            new_user_codename=session.get('new_user_codename', None),\n            form=SubmissionForm(),\n        )\n\n    @view.route('/submit', methods=('POST',))\n    @login_required\n    def submit(logged_in_source: SourceUser) -> werkzeug.Response:\n        allow_document_uploads = InstanceConfig.get_default().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(f\"{url_for('main.lookup')}#flashed\")\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(f\"{url_for('main.lookup')}#flashed\")\n\n        fnames = []\n        logged_in_source_in_db = logged_in_source.get_db_record()\n        first_submission = logged_in_source_in_db.interaction_count == 0\n\n        if not os.path.exists(Storage.get_default().path(logged_in_source.filesystem_id)):\n            current_app.logger.debug(\"Store directory not found for source '{}', creating one.\"\n                                     .format(logged_in_source_in_db.journalist_designation))\n            os.mkdir(Storage.get_default().path(logged_in_source.filesystem_id))\n\n        if msg:\n            logged_in_source_in_db.interaction_count += 1\n            fnames.append(\n                Storage.get_default().save_message_submission(\n                    logged_in_source_in_db.filesystem_id,\n                    logged_in_source_in_db.interaction_count,\n                    logged_in_source_in_db.journalist_filename,\n                    msg))\n        if fh:\n            logged_in_source_in_db.interaction_count += 1\n            fnames.append(\n                Storage.get_default().save_file_submission(\n                    logged_in_source_in_db.filesystem_id,\n                    logged_in_source_in_db.interaction_count,\n                    logged_in_source_in_db.journalist_filename,\n                    fh.filename,\n                    fh.stream))\n\n        if first_submission:\n            flash_message = render_template(\n                'first_submission_flashed_message.html',\n                new_user_codename=session.get('new_user_codename', None),\n            )\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(logged_in_source_in_db, fname, Storage.get_default())\n            db.session.add(submission)\n            new_submissions.append(submission)\n\n        logged_in_source_in_db.pending = False\n        logged_in_source_in_db.last_updated = datetime.now(timezone.utc)\n        db.session.commit()\n\n        for sub in new_submissions:\n            store.async_add_checksum_for_file(sub, Storage.get_default())\n\n        normalize_timestamps(logged_in_source)\n\n        return redirect(f\"{url_for('main.lookup')}#flashed\")\n\n    @view.route('/delete', methods=('POST',))\n    @login_required\n    def delete(logged_in_source: SourceUser) -> 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=logged_in_source.db_record_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(logged_in_source: SourceUser) -> werkzeug.Response:\n        replies = Reply.query.filter(Reply.source_id == logged_in_source.db_record_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            try:\n                SessionManager.log_user_in(\n                    db_session=db.session,\n                    supplied_passphrase=DicewarePassphrase(request.form['codename'].strip())\n                )\n            except InvalidPassphraseError:\n                current_app.logger.info(\"Login failed for invalid codename\")\n                flash(gettext(\"Sorry, that is not a recognized codename.\"), \"error\")\n            else:\n                # Success: a valid passphrase was supplied\n                return redirect(url_for('.lookup', from_login='1'))\n\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 SessionManager.is_user_logged_in(db_session=db.session):\n            SessionManager.log_user_out()\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    @view.route('/robots.txt')\n    def robots_txt() -> werkzeug.Response:\n        \"\"\"Tell robots we don't want them\"\"\"\n        resp = make_response(\"User-agent: *\\nDisallow: /\")\n        resp.headers[\"content-type\"] = \"text/plain\"\n        return resp\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, timedelta, timezone\nfrom typing import Union\n\nimport werkzeug\nfrom flask import (Blueprint, render_template, flash, redirect, url_for,\n                   session, current_app, request, Markup, abort, g)\nfrom flask_babel import gettext\n\nimport store\n\nfrom store import Storage\n\nfrom db import db\nfrom encryption import EncryptionManager, GpgKeyNotFoundError\n\nfrom models import Submission, Reply, get_one_or_else, InstanceConfig\nfrom passphrases import PassphraseGenerator, DicewarePassphrase\nfrom sdconfig import SDConfig\nfrom source_app.decorators import login_required\nfrom source_app.session_manager import SessionManager\nfrom source_app.utils import normalize_timestamps, fit_codenames_into_cookie, \\\n    clear_session_and_redirect_to_logged_out_page\nfrom source_app.forms import LoginForm, SubmissionForm\nfrom source_user import InvalidPassphraseError, create_source_user, \\\n    SourcePassphraseCollisionError, SourceDesignationCollisionError, SourceUser\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=('POST',))\n    def generate() -> Union[str, werkzeug.Response]:\n        # Try to detect Tor2Web usage by looking to see if tor2web_check got mangled\n        tor2web_check = request.form.get('tor2web_check')\n        if tor2web_check is None:\n            # Missing form field\n            abort(403)\n        elif tor2web_check != 'href=\"fake.onion\"':\n            return redirect(url_for('info.tor2web_warning'))\n        if SessionManager.is_user_logged_in(db_session=db.session):\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        codename = PassphraseGenerator.get_default().generate_passphrase(\n            preferred_language=g.localeinfo.language\n        )\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'] = fit_codenames_into_cookie(codenames)\n        session[\"codenames_expire\"] = datetime.now(timezone.utc) + timedelta(\n            minutes=config.SESSION_EXPIRATION_MINUTES\n        )\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 SessionManager.is_user_logged_in(db_session=db.session):\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            # Ensure the codenames have not expired\n            date_codenames_expire = session.get(\"codenames_expire\")\n            if not date_codenames_expire or datetime.now(timezone.utc) >= date_codenames_expire:\n                return clear_session_and_redirect_to_logged_out_page(flask_session=session)\n\n            tab_id = request.form['tab_id']\n            codename = session['codenames'][tab_id]\n            del session['codenames']\n\n            try:\n                current_app.logger.info(\"Creating new source user...\")\n                create_source_user(\n                    db_session=db.session,\n                    source_passphrase=codename,\n                    source_app_storage=Storage.get_default(),\n                )\n            except (SourcePassphraseCollisionError, SourceDesignationCollisionError) as e:\n                current_app.logger.error(\"Could not create a source: {}\".format(e))\n                flash(\n                    gettext(\n                        \"There was a temporary problem creating your account. Please try again.\"\n                    ),\n                    \"error\"\n                )\n                return redirect(url_for('.index'))\n\n            # All done - source user was successfully created\n            current_app.logger.info(\"New source user created\")\n            session['new_user_codename'] = codename\n            SessionManager.log_user_in(db_session=db.session,\n                                       supplied_passphrase=DicewarePassphrase(codename))\n\n        return redirect(url_for('.lookup'))\n\n    @view.route('/lookup', methods=('GET',))\n    @login_required\n    def lookup(logged_in_source: SourceUser) -> str:\n        replies = []\n        logged_in_source_in_db = logged_in_source.get_db_record()\n        source_inbox = Reply.query.filter_by(\n            source_id=logged_in_source_in_db.id, deleted_by_source=False\n        ).all()\n\n        for reply in source_inbox:\n            reply_path = Storage.get_default().path(\n                logged_in_source.filesystem_id,\n                reply.filename,\n            )\n            try:\n                with io.open(reply_path, \"rb\") as f:\n                    contents = f.read()\n                decrypted_reply = EncryptionManager.get_default().decrypt_journalist_reply(\n                    for_source_user=logged_in_source,\n                    ciphertext_in=contents\n                )\n                reply.decrypted = decrypted_reply\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        # If not done yet, generate a keypair to encrypt replies from the journalist\n        encryption_mgr = EncryptionManager.get_default()\n        try:\n            encryption_mgr.get_source_public_key(logged_in_source.filesystem_id)\n        except GpgKeyNotFoundError:\n            encryption_mgr.generate_source_key_pair(logged_in_source)\n\n        return render_template(\n            'lookup.html',\n            is_user_logged_in=True,\n            allow_document_uploads=InstanceConfig.get_default().allow_document_uploads,\n            replies=replies,\n            new_user_codename=session.get('new_user_codename', None),\n            form=SubmissionForm(),\n        )\n\n    @view.route('/submit', methods=('POST',))\n    @login_required\n    def submit(logged_in_source: SourceUser) -> werkzeug.Response:\n        allow_document_uploads = InstanceConfig.get_default().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(f\"{url_for('main.lookup')}#flashed\")\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(f\"{url_for('main.lookup')}#flashed\")\n\n        fnames = []\n        logged_in_source_in_db = logged_in_source.get_db_record()\n        first_submission = logged_in_source_in_db.interaction_count == 0\n\n        if not os.path.exists(Storage.get_default().path(logged_in_source.filesystem_id)):\n            current_app.logger.debug(\"Store directory not found for source '{}', creating one.\"\n                                     .format(logged_in_source_in_db.journalist_designation))\n            os.mkdir(Storage.get_default().path(logged_in_source.filesystem_id))\n\n        if msg:\n            logged_in_source_in_db.interaction_count += 1\n            fnames.append(\n                Storage.get_default().save_message_submission(\n                    logged_in_source_in_db.filesystem_id,\n                    logged_in_source_in_db.interaction_count,\n                    logged_in_source_in_db.journalist_filename,\n                    msg))\n        if fh:\n            logged_in_source_in_db.interaction_count += 1\n            fnames.append(\n                Storage.get_default().save_file_submission(\n                    logged_in_source_in_db.filesystem_id,\n                    logged_in_source_in_db.interaction_count,\n                    logged_in_source_in_db.journalist_filename,\n                    fh.filename,\n                    fh.stream))\n\n        if first_submission:\n            flash_message = render_template(\n                'first_submission_flashed_message.html',\n                new_user_codename=session.get('new_user_codename', None),\n            )\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(logged_in_source_in_db, fname, Storage.get_default())\n            db.session.add(submission)\n            new_submissions.append(submission)\n\n        logged_in_source_in_db.pending = False\n        logged_in_source_in_db.last_updated = datetime.now(timezone.utc)\n        db.session.commit()\n\n        for sub in new_submissions:\n            store.async_add_checksum_for_file(sub, Storage.get_default())\n\n        normalize_timestamps(logged_in_source)\n\n        return redirect(f\"{url_for('main.lookup')}#flashed\")\n\n    @view.route('/delete', methods=('POST',))\n    @login_required\n    def delete(logged_in_source: SourceUser) -> 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=logged_in_source.db_record_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(logged_in_source: SourceUser) -> werkzeug.Response:\n        replies = Reply.query.filter(Reply.source_id == logged_in_source.db_record_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            try:\n                SessionManager.log_user_in(\n                    db_session=db.session,\n                    supplied_passphrase=DicewarePassphrase(request.form['codename'].strip())\n                )\n            except InvalidPassphraseError:\n                current_app.logger.info(\"Login failed for invalid codename\")\n                flash(gettext(\"Sorry, that is not a recognized codename.\"), \"error\")\n            else:\n                # Success: a valid passphrase was supplied\n                return redirect(url_for('.lookup', from_login='1'))\n\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 SessionManager.is_user_logged_in(db_session=db.session):\n            SessionManager.log_user_out()\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"}]}