We are currently solving the following issue within our repository. Here is the issue text:
--- BEGIN ISSUE ---
allow clients to set UUID for a reply
## Description

Currently we return the UUID of a reply after the reply has been stored. In order for clients with spotty connectivity to be able to store a reply locally and then confirm it has been stored by the server, they need to have some sort of ID that is sent to the server and then returned. We might as well make this the UUID.

## User Stories

As a developer working on the client, I want to be able to send a bunch of replies over a very slow / spotty connection and know which ones were stored and which ones failed.


--- 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/journalist_app/api.py`
Content:
```
1 import json
2 
3 from datetime import datetime, timedelta
4 from flask import abort, Blueprint, current_app, jsonify, request
5 from functools import wraps
6 from os import path
7 from werkzeug.exceptions import default_exceptions  # type: ignore
8 
9 from db import db
10 from journalist_app import utils
11 from models import (Journalist, Reply, Source, Submission,
12                     LoginThrottledException, InvalidUsernameException,
13                     BadTokenException, WrongPasswordException)
14 from store import NotEncrypted
15 
16 
17 TOKEN_EXPIRATION_MINS = 60 * 8
18 
19 
20 def get_user_object(request):
21     """Helper function to use in token_required views that need a user
22     object
23     """
24     auth_token = request.headers.get('Authorization').split(" ")[1]
25     user = Journalist.validate_api_token_and_get_user(auth_token)
26     return user
27 
28 
29 def token_required(f):
30     @wraps(f)
31     def decorated_function(*args, **kwargs):
32         try:
33             auth_header = request.headers['Authorization']
34         except KeyError:
35             return abort(403, 'API token not found in Authorization header.')
36 
37         if auth_header:
38             auth_token = auth_header.split(" ")[1]
39         else:
40             auth_token = ''
41         if not Journalist.validate_api_token_and_get_user(auth_token):
42             return abort(403, 'API token is invalid or expired.')
43         return f(*args, **kwargs)
44     return decorated_function
45 
46 
47 def get_or_404(model, object_id, column=''):
48     if column:
49         result = model.query.filter(column == object_id).one_or_none()
50     else:
51         result = model.query.get(object_id)
52     if result is None:
53         abort(404)
54     return result
55 
56 
57 def make_blueprint(config):
58     api = Blueprint('api', __name__)
59 
60     @api.route('/')
61     def get_endpoints():
62         endpoints = {'sources_url': '/api/v1/sources',
63                      'current_user_url': '/api/v1/user',
64                      'submissions_url': '/api/v1/submissions',
65                      'replies_url': '/api/v1/replies',
66                      'auth_token_url': '/api/v1/token'}
67         return jsonify(endpoints), 200
68 
69     # Before every post, we validate the payload before processing the request
70     @api.before_request
71     def validate_data():
72         if request.method == 'POST':
73             # flag and star can have empty payloads
74             if not request.data:
75                 if ('flag' not in request.path and 'star' not in request.path):
76                     return abort(400, 'malformed request')
77             # other requests must have valid JSON payload
78             else:
79                 try:
80                     json.loads(request.data)
81                 except (ValueError):
82                     return abort(400, 'malformed request')
83 
84     @api.route('/token', methods=['POST'])
85     def get_token():
86         creds = json.loads(request.data)
87 
88         username = creds.get('username', None)
89         passphrase = creds.get('passphrase', None)
90         one_time_code = creds.get('one_time_code', None)
91 
92         if username is None:
93             return abort(400, 'username field is missing')
94         if passphrase is None:
95             return abort(400, 'passphrase field is missing')
96         if one_time_code is None:
97             return abort(400, 'one_time_code field is missing')
98 
99         try:
100             journalist = Journalist.login(username, passphrase, one_time_code)
101             token_expiry = datetime.utcnow() + timedelta(
102                 seconds=TOKEN_EXPIRATION_MINS * 60)
103             response = jsonify({'token': journalist.generate_api_token(
104                  expiration=TOKEN_EXPIRATION_MINS * 60),
105                  'expiration': token_expiry.isoformat() + 'Z'})
106 
107             # Update access metadata
108             journalist.last_access = datetime.utcnow()
109             db.session.add(journalist)
110             db.session.commit()
111 
112             return response, 200
113         except (LoginThrottledException, InvalidUsernameException,
114                 BadTokenException, WrongPasswordException):
115             return abort(403, 'Token authentication failed.')
116 
117     @api.route('/sources', methods=['GET'])
118     @token_required
119     def get_all_sources():
120         sources = Source.query.filter_by(pending=False).all()
121         return jsonify(
122             {'sources': [source.to_json() for source in sources]}), 200
123 
124     @api.route('/sources/<source_uuid>', methods=['GET', 'DELETE'])
125     @token_required
126     def single_source(source_uuid):
127         if request.method == 'GET':
128             source = get_or_404(Source, source_uuid, column=Source.uuid)
129             return jsonify(source.to_json()), 200
130         elif request.method == 'DELETE':
131             source = get_or_404(Source, source_uuid, column=Source.uuid)
132             utils.delete_collection(source.filesystem_id)
133             return jsonify({'message': 'Source and submissions deleted'}), 200
134 
135     @api.route('/sources/<source_uuid>/add_star', methods=['POST'])
136     @token_required
137     def add_star(source_uuid):
138         source = get_or_404(Source, source_uuid, column=Source.uuid)
139         utils.make_star_true(source.filesystem_id)
140         db.session.commit()
141         return jsonify({'message': 'Star added'}), 201
142 
143     @api.route('/sources/<source_uuid>/remove_star', methods=['DELETE'])
144     @token_required
145     def remove_star(source_uuid):
146         source = get_or_404(Source, source_uuid, column=Source.uuid)
147         utils.make_star_false(source.filesystem_id)
148         db.session.commit()
149         return jsonify({'message': 'Star removed'}), 200
150 
151     @api.route('/sources/<source_uuid>/flag', methods=['POST'])
152     @token_required
153     def flag(source_uuid):
154         source = get_or_404(Source, source_uuid,
155                             column=Source.uuid)
156         source.flagged = True
157         db.session.commit()
158         return jsonify({'message': 'Source flagged for reply'}), 200
159 
160     @api.route('/sources/<source_uuid>/submissions', methods=['GET'])
161     @token_required
162     def all_source_submissions(source_uuid):
163         source = get_or_404(Source, source_uuid, column=Source.uuid)
164         return jsonify(
165             {'submissions': [submission.to_json() for
166                              submission in source.submissions]}), 200
167 
168     @api.route('/sources/<source_uuid>/submissions/<submission_uuid>/download',  # noqa
169                methods=['GET'])
170     @token_required
171     def download_submission(source_uuid, submission_uuid):
172         source = get_or_404(Source, source_uuid, column=Source.uuid)
173         submission = get_or_404(Submission, submission_uuid,
174                                 column=Submission.uuid)
175 
176         # Mark as downloaded
177         submission.downloaded = True
178         db.session.commit()
179 
180         return utils.serve_file_with_etag(source, submission.filename)
181 
182     @api.route('/sources/<source_uuid>/replies/<reply_uuid>/download',
183                methods=['GET'])
184     @token_required
185     def download_reply(source_uuid, reply_uuid):
186         source = get_or_404(Source, source_uuid, column=Source.uuid)
187         reply = get_or_404(Reply, reply_uuid, column=Reply.uuid)
188 
189         return utils.serve_file_with_etag(source, reply.filename)
190 
191     @api.route('/sources/<source_uuid>/submissions/<submission_uuid>',
192                methods=['GET', 'DELETE'])
193     @token_required
194     def single_submission(source_uuid, submission_uuid):
195         if request.method == 'GET':
196             source = get_or_404(Source, source_uuid, column=Source.uuid)
197             submission = get_or_404(Submission, submission_uuid,
198                                     column=Submission.uuid)
199             return jsonify(submission.to_json()), 200
200         elif request.method == 'DELETE':
201             submission = get_or_404(Submission, submission_uuid,
202                                     column=Submission.uuid)
203             source = get_or_404(Source, source_uuid, column=Source.uuid)
204             utils.delete_file(source.filesystem_id, submission.filename,
205                               submission)
206             return jsonify({'message': 'Submission deleted'}), 200
207 
208     @api.route('/sources/<source_uuid>/replies', methods=['GET', 'POST'])
209     @token_required
210     def all_source_replies(source_uuid):
211         if request.method == 'GET':
212             source = get_or_404(Source, source_uuid, column=Source.uuid)
213             return jsonify(
214                 {'replies': [reply.to_json() for
215                              reply in source.replies]}), 200
216         elif request.method == 'POST':
217             source = get_or_404(Source, source_uuid,
218                                 column=Source.uuid)
219             if request.json is None:
220                 abort(400, 'please send requests in valid JSON')
221 
222             if 'reply' not in request.json:
223                 abort(400, 'reply not found in request body')
224 
225             user = get_user_object(request)
226 
227             data = json.loads(request.data)
228             if not data['reply']:
229                 abort(400, 'reply should not be empty')
230 
231             source.interaction_count += 1
232             try:
233                 filename = current_app.storage.save_pre_encrypted_reply(
234                     source.filesystem_id,
235                     source.interaction_count,
236                     source.journalist_filename,
237                     data['reply'])
238             except NotEncrypted:
239                 return jsonify(
240                     {'message': 'You must encrypt replies client side'}), 400
241 
242             # issue #3918
243             filename = path.basename(filename)
244 
245             reply = Reply(user, source, filename)
246             db.session.add(reply)
247             db.session.add(source)
248             db.session.commit()
249             return jsonify({'message': 'Your reply has been stored',
250                             'uuid': reply.uuid}), 201
251 
252     @api.route('/sources/<source_uuid>/replies/<reply_uuid>',
253                methods=['GET', 'DELETE'])
254     @token_required
255     def single_reply(source_uuid, reply_uuid):
256         source = get_or_404(Source, source_uuid, column=Source.uuid)
257         reply = get_or_404(Reply, reply_uuid, column=Reply.uuid)
258         if request.method == 'GET':
259             return jsonify(reply.to_json()), 200
260         elif request.method == 'DELETE':
261             utils.delete_file(source.filesystem_id, reply.filename,
262                               reply)
263             return jsonify({'message': 'Reply deleted'}), 200
264 
265     @api.route('/submissions', methods=['GET'])
266     @token_required
267     def get_all_submissions():
268         submissions = Submission.query.all()
269         return jsonify({'submissions': [submission.to_json() for
270                                         submission in submissions]}), 200
271 
272     @api.route('/replies', methods=['GET'])
273     @token_required
274     def get_all_replies():
275         replies = Reply.query.all()
276         return jsonify(
277             {'replies': [reply.to_json() for reply in replies]}), 200
278 
279     @api.route('/user', methods=['GET'])
280     @token_required
281     def get_current_user():
282         user = get_user_object(request)
283         return jsonify(user.to_json()), 200
284 
285     def _handle_http_exception(error):
286         # Workaround for no blueprint-level 404/5 error handlers, see:
287         # https://github.com/pallets/flask/issues/503#issuecomment-71383286
288         response = jsonify({'error': error.name,
289                            'message': error.description})
290 
291         return response, error.code
292 
293     for code in default_exceptions:
294         api.errorhandler(code)(_handle_http_exception)
295 
296     return api
297
```
--- 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/journalist_app/api.py b/securedrop/journalist_app/api.py
--- a/securedrop/journalist_app/api.py
+++ b/securedrop/journalist_app/api.py
@@ -3,7 +3,9 @@
 from datetime import datetime, timedelta
 from flask import abort, Blueprint, current_app, jsonify, request
 from functools import wraps
+from sqlalchemy.exc import IntegrityError
 from os import path
+from uuid import UUID
 from werkzeug.exceptions import default_exceptions  # type: ignore
 
 from db import db
@@ -243,9 +245,27 @@
             filename = path.basename(filename)
 
             reply = Reply(user, source, filename)
-            db.session.add(reply)
-            db.session.add(source)
-            db.session.commit()
+
+            reply_uuid = data.get('uuid', None)
+            if reply_uuid is not None:
+                # check that is is parseable
+                try:
+                    UUID(reply_uuid)
+                except ValueError:
+                    abort(400, "'uuid' was not a valid UUID")
+                reply.uuid = reply_uuid
+
+            try:
+                db.session.add(reply)
+                db.session.add(source)
+                db.session.commit()
+            except IntegrityError as e:
+                db.session.rollback()
+                if 'UNIQUE constraint failed: replies.uuid' in str(e):
+                    abort(409, 'That UUID is already in use.')
+                else:
+                    raise e
+
             return jsonify({'message': 'Your reply has been stored',
                             'uuid': reply.uuid}), 201

{"golden_diff": "diff --git a/securedrop/journalist_app/api.py b/securedrop/journalist_app/api.py\n--- a/securedrop/journalist_app/api.py\n+++ b/securedrop/journalist_app/api.py\n@@ -3,7 +3,9 @@\n from datetime import datetime, timedelta\n from flask import abort, Blueprint, current_app, jsonify, request\n from functools import wraps\n+from sqlalchemy.exc import IntegrityError\n from os import path\n+from uuid import UUID\n from werkzeug.exceptions import default_exceptions  # type: ignore\n \n from db import db\n@@ -243,9 +245,27 @@\n             filename = path.basename(filename)\n \n             reply = Reply(user, source, filename)\n-            db.session.add(reply)\n-            db.session.add(source)\n-            db.session.commit()\n+\n+            reply_uuid = data.get('uuid', None)\n+            if reply_uuid is not None:\n+                # check that is is parseable\n+                try:\n+                    UUID(reply_uuid)\n+                except ValueError:\n+                    abort(400, \"'uuid' was not a valid UUID\")\n+                reply.uuid = reply_uuid\n+\n+            try:\n+                db.session.add(reply)\n+                db.session.add(source)\n+                db.session.commit()\n+            except IntegrityError as e:\n+                db.session.rollback()\n+                if 'UNIQUE constraint failed: replies.uuid' in str(e):\n+                    abort(409, 'That UUID is already in use.')\n+                else:\n+                    raise e\n+\n             return jsonify({'message': 'Your reply has been stored',\n                             'uuid': reply.uuid}), 201\n", "issue": "allow clients to set UUID for a reply\n## Description\r\n\r\nCurrently we return the UUID of a reply after the reply has been stored. In order for clients with spotty connectivity to be able to store a reply locally and then confirm it has been stored by the server, they need to have some sort of ID that is sent to the server and then returned. We might as well make this the UUID.\r\n\r\n## User Stories\r\n\r\nAs a developer working on the client, I want to be able to send a bunch of replies over a very slow / spotty connection and know which ones were stored and which ones failed.\r\n\n", "before_files": [{"content": "import json\n\nfrom datetime import datetime, timedelta\nfrom flask import abort, Blueprint, current_app, jsonify, request\nfrom functools import wraps\nfrom os import path\nfrom werkzeug.exceptions import default_exceptions  # type: ignore\n\nfrom db import db\nfrom journalist_app import utils\nfrom models import (Journalist, Reply, Source, Submission,\n                    LoginThrottledException, InvalidUsernameException,\n                    BadTokenException, WrongPasswordException)\nfrom store import NotEncrypted\n\n\nTOKEN_EXPIRATION_MINS = 60 * 8\n\n\ndef get_user_object(request):\n    \"\"\"Helper function to use in token_required views that need a user\n    object\n    \"\"\"\n    auth_token = request.headers.get('Authorization').split(\" \")[1]\n    user = Journalist.validate_api_token_and_get_user(auth_token)\n    return user\n\n\ndef token_required(f):\n    @wraps(f)\n    def decorated_function(*args, **kwargs):\n        try:\n            auth_header = request.headers['Authorization']\n        except KeyError:\n            return abort(403, 'API token not found in Authorization header.')\n\n        if auth_header:\n            auth_token = auth_header.split(\" \")[1]\n        else:\n            auth_token = ''\n        if not Journalist.validate_api_token_and_get_user(auth_token):\n            return abort(403, 'API token is invalid or expired.')\n        return f(*args, **kwargs)\n    return decorated_function\n\n\ndef get_or_404(model, object_id, column=''):\n    if column:\n        result = model.query.filter(column == object_id).one_or_none()\n    else:\n        result = model.query.get(object_id)\n    if result is None:\n        abort(404)\n    return result\n\n\ndef make_blueprint(config):\n    api = Blueprint('api', __name__)\n\n    @api.route('/')\n    def get_endpoints():\n        endpoints = {'sources_url': '/api/v1/sources',\n                     'current_user_url': '/api/v1/user',\n                     'submissions_url': '/api/v1/submissions',\n                     'replies_url': '/api/v1/replies',\n                     'auth_token_url': '/api/v1/token'}\n        return jsonify(endpoints), 200\n\n    # Before every post, we validate the payload before processing the request\n    @api.before_request\n    def validate_data():\n        if request.method == 'POST':\n            # flag and star can have empty payloads\n            if not request.data:\n                if ('flag' not in request.path and 'star' not in request.path):\n                    return abort(400, 'malformed request')\n            # other requests must have valid JSON payload\n            else:\n                try:\n                    json.loads(request.data)\n                except (ValueError):\n                    return abort(400, 'malformed request')\n\n    @api.route('/token', methods=['POST'])\n    def get_token():\n        creds = json.loads(request.data)\n\n        username = creds.get('username', None)\n        passphrase = creds.get('passphrase', None)\n        one_time_code = creds.get('one_time_code', None)\n\n        if username is None:\n            return abort(400, 'username field is missing')\n        if passphrase is None:\n            return abort(400, 'passphrase field is missing')\n        if one_time_code is None:\n            return abort(400, 'one_time_code field is missing')\n\n        try:\n            journalist = Journalist.login(username, passphrase, one_time_code)\n            token_expiry = datetime.utcnow() + timedelta(\n                seconds=TOKEN_EXPIRATION_MINS * 60)\n            response = jsonify({'token': journalist.generate_api_token(\n                 expiration=TOKEN_EXPIRATION_MINS * 60),\n                 'expiration': token_expiry.isoformat() + 'Z'})\n\n            # Update access metadata\n            journalist.last_access = datetime.utcnow()\n            db.session.add(journalist)\n            db.session.commit()\n\n            return response, 200\n        except (LoginThrottledException, InvalidUsernameException,\n                BadTokenException, WrongPasswordException):\n            return abort(403, 'Token authentication failed.')\n\n    @api.route('/sources', methods=['GET'])\n    @token_required\n    def get_all_sources():\n        sources = Source.query.filter_by(pending=False).all()\n        return jsonify(\n            {'sources': [source.to_json() for source in sources]}), 200\n\n    @api.route('/sources/<source_uuid>', methods=['GET', 'DELETE'])\n    @token_required\n    def single_source(source_uuid):\n        if request.method == 'GET':\n            source = get_or_404(Source, source_uuid, column=Source.uuid)\n            return jsonify(source.to_json()), 200\n        elif request.method == 'DELETE':\n            source = get_or_404(Source, source_uuid, column=Source.uuid)\n            utils.delete_collection(source.filesystem_id)\n            return jsonify({'message': 'Source and submissions deleted'}), 200\n\n    @api.route('/sources/<source_uuid>/add_star', methods=['POST'])\n    @token_required\n    def add_star(source_uuid):\n        source = get_or_404(Source, source_uuid, column=Source.uuid)\n        utils.make_star_true(source.filesystem_id)\n        db.session.commit()\n        return jsonify({'message': 'Star added'}), 201\n\n    @api.route('/sources/<source_uuid>/remove_star', methods=['DELETE'])\n    @token_required\n    def remove_star(source_uuid):\n        source = get_or_404(Source, source_uuid, column=Source.uuid)\n        utils.make_star_false(source.filesystem_id)\n        db.session.commit()\n        return jsonify({'message': 'Star removed'}), 200\n\n    @api.route('/sources/<source_uuid>/flag', methods=['POST'])\n    @token_required\n    def flag(source_uuid):\n        source = get_or_404(Source, source_uuid,\n                            column=Source.uuid)\n        source.flagged = True\n        db.session.commit()\n        return jsonify({'message': 'Source flagged for reply'}), 200\n\n    @api.route('/sources/<source_uuid>/submissions', methods=['GET'])\n    @token_required\n    def all_source_submissions(source_uuid):\n        source = get_or_404(Source, source_uuid, column=Source.uuid)\n        return jsonify(\n            {'submissions': [submission.to_json() for\n                             submission in source.submissions]}), 200\n\n    @api.route('/sources/<source_uuid>/submissions/<submission_uuid>/download',  # noqa\n               methods=['GET'])\n    @token_required\n    def download_submission(source_uuid, submission_uuid):\n        source = get_or_404(Source, source_uuid, column=Source.uuid)\n        submission = get_or_404(Submission, submission_uuid,\n                                column=Submission.uuid)\n\n        # Mark as downloaded\n        submission.downloaded = True\n        db.session.commit()\n\n        return utils.serve_file_with_etag(source, submission.filename)\n\n    @api.route('/sources/<source_uuid>/replies/<reply_uuid>/download',\n               methods=['GET'])\n    @token_required\n    def download_reply(source_uuid, reply_uuid):\n        source = get_or_404(Source, source_uuid, column=Source.uuid)\n        reply = get_or_404(Reply, reply_uuid, column=Reply.uuid)\n\n        return utils.serve_file_with_etag(source, reply.filename)\n\n    @api.route('/sources/<source_uuid>/submissions/<submission_uuid>',\n               methods=['GET', 'DELETE'])\n    @token_required\n    def single_submission(source_uuid, submission_uuid):\n        if request.method == 'GET':\n            source = get_or_404(Source, source_uuid, column=Source.uuid)\n            submission = get_or_404(Submission, submission_uuid,\n                                    column=Submission.uuid)\n            return jsonify(submission.to_json()), 200\n        elif request.method == 'DELETE':\n            submission = get_or_404(Submission, submission_uuid,\n                                    column=Submission.uuid)\n            source = get_or_404(Source, source_uuid, column=Source.uuid)\n            utils.delete_file(source.filesystem_id, submission.filename,\n                              submission)\n            return jsonify({'message': 'Submission deleted'}), 200\n\n    @api.route('/sources/<source_uuid>/replies', methods=['GET', 'POST'])\n    @token_required\n    def all_source_replies(source_uuid):\n        if request.method == 'GET':\n            source = get_or_404(Source, source_uuid, column=Source.uuid)\n            return jsonify(\n                {'replies': [reply.to_json() for\n                             reply in source.replies]}), 200\n        elif request.method == 'POST':\n            source = get_or_404(Source, source_uuid,\n                                column=Source.uuid)\n            if request.json is None:\n                abort(400, 'please send requests in valid JSON')\n\n            if 'reply' not in request.json:\n                abort(400, 'reply not found in request body')\n\n            user = get_user_object(request)\n\n            data = json.loads(request.data)\n            if not data['reply']:\n                abort(400, 'reply should not be empty')\n\n            source.interaction_count += 1\n            try:\n                filename = current_app.storage.save_pre_encrypted_reply(\n                    source.filesystem_id,\n                    source.interaction_count,\n                    source.journalist_filename,\n                    data['reply'])\n            except NotEncrypted:\n                return jsonify(\n                    {'message': 'You must encrypt replies client side'}), 400\n\n            # issue #3918\n            filename = path.basename(filename)\n\n            reply = Reply(user, source, filename)\n            db.session.add(reply)\n            db.session.add(source)\n            db.session.commit()\n            return jsonify({'message': 'Your reply has been stored',\n                            'uuid': reply.uuid}), 201\n\n    @api.route('/sources/<source_uuid>/replies/<reply_uuid>',\n               methods=['GET', 'DELETE'])\n    @token_required\n    def single_reply(source_uuid, reply_uuid):\n        source = get_or_404(Source, source_uuid, column=Source.uuid)\n        reply = get_or_404(Reply, reply_uuid, column=Reply.uuid)\n        if request.method == 'GET':\n            return jsonify(reply.to_json()), 200\n        elif request.method == 'DELETE':\n            utils.delete_file(source.filesystem_id, reply.filename,\n                              reply)\n            return jsonify({'message': 'Reply deleted'}), 200\n\n    @api.route('/submissions', methods=['GET'])\n    @token_required\n    def get_all_submissions():\n        submissions = Submission.query.all()\n        return jsonify({'submissions': [submission.to_json() for\n                                        submission in submissions]}), 200\n\n    @api.route('/replies', methods=['GET'])\n    @token_required\n    def get_all_replies():\n        replies = Reply.query.all()\n        return jsonify(\n            {'replies': [reply.to_json() for reply in replies]}), 200\n\n    @api.route('/user', methods=['GET'])\n    @token_required\n    def get_current_user():\n        user = get_user_object(request)\n        return jsonify(user.to_json()), 200\n\n    def _handle_http_exception(error):\n        # Workaround for no blueprint-level 404/5 error handlers, see:\n        # https://github.com/pallets/flask/issues/503#issuecomment-71383286\n        response = jsonify({'error': error.name,\n                           'message': error.description})\n\n        return response, error.code\n\n    for code in default_exceptions:\n        api.errorhandler(code)(_handle_http_exception)\n\n    return api\n", "path": "securedrop/journalist_app/api.py"}], "after_files": [{"content": "import json\n\nfrom datetime import datetime, timedelta\nfrom flask import abort, Blueprint, current_app, jsonify, request\nfrom functools import wraps\nfrom sqlalchemy.exc import IntegrityError\nfrom os import path\nfrom uuid import UUID\nfrom werkzeug.exceptions import default_exceptions  # type: ignore\n\nfrom db import db\nfrom journalist_app import utils\nfrom models import (Journalist, Reply, Source, Submission,\n                    LoginThrottledException, InvalidUsernameException,\n                    BadTokenException, WrongPasswordException)\nfrom store import NotEncrypted\n\n\nTOKEN_EXPIRATION_MINS = 60 * 8\n\n\ndef get_user_object(request):\n    \"\"\"Helper function to use in token_required views that need a user\n    object\n    \"\"\"\n    auth_token = request.headers.get('Authorization').split(\" \")[1]\n    user = Journalist.validate_api_token_and_get_user(auth_token)\n    return user\n\n\ndef token_required(f):\n    @wraps(f)\n    def decorated_function(*args, **kwargs):\n        try:\n            auth_header = request.headers['Authorization']\n        except KeyError:\n            return abort(403, 'API token not found in Authorization header.')\n\n        if auth_header:\n            auth_token = auth_header.split(\" \")[1]\n        else:\n            auth_token = ''\n        if not Journalist.validate_api_token_and_get_user(auth_token):\n            return abort(403, 'API token is invalid or expired.')\n        return f(*args, **kwargs)\n    return decorated_function\n\n\ndef get_or_404(model, object_id, column=''):\n    if column:\n        result = model.query.filter(column == object_id).one_or_none()\n    else:\n        result = model.query.get(object_id)\n    if result is None:\n        abort(404)\n    return result\n\n\ndef make_blueprint(config):\n    api = Blueprint('api', __name__)\n\n    @api.route('/')\n    def get_endpoints():\n        endpoints = {'sources_url': '/api/v1/sources',\n                     'current_user_url': '/api/v1/user',\n                     'submissions_url': '/api/v1/submissions',\n                     'replies_url': '/api/v1/replies',\n                     'auth_token_url': '/api/v1/token'}\n        return jsonify(endpoints), 200\n\n    # Before every post, we validate the payload before processing the request\n    @api.before_request\n    def validate_data():\n        if request.method == 'POST':\n            # flag and star can have empty payloads\n            if not request.data:\n                if ('flag' not in request.path and 'star' not in request.path):\n                    return abort(400, 'malformed request')\n            # other requests must have valid JSON payload\n            else:\n                try:\n                    json.loads(request.data)\n                except (ValueError):\n                    return abort(400, 'malformed request')\n\n    @api.route('/token', methods=['POST'])\n    def get_token():\n        creds = json.loads(request.data)\n\n        username = creds.get('username', None)\n        passphrase = creds.get('passphrase', None)\n        one_time_code = creds.get('one_time_code', None)\n\n        if username is None:\n            return abort(400, 'username field is missing')\n        if passphrase is None:\n            return abort(400, 'passphrase field is missing')\n        if one_time_code is None:\n            return abort(400, 'one_time_code field is missing')\n\n        try:\n            journalist = Journalist.login(username, passphrase, one_time_code)\n            token_expiry = datetime.utcnow() + timedelta(\n                seconds=TOKEN_EXPIRATION_MINS * 60)\n            response = jsonify({'token': journalist.generate_api_token(\n                 expiration=TOKEN_EXPIRATION_MINS * 60),\n                 'expiration': token_expiry.isoformat() + 'Z'})\n\n            # Update access metadata\n            journalist.last_access = datetime.utcnow()\n            db.session.add(journalist)\n            db.session.commit()\n\n            return response, 200\n        except (LoginThrottledException, InvalidUsernameException,\n                BadTokenException, WrongPasswordException):\n            return abort(403, 'Token authentication failed.')\n\n    @api.route('/sources', methods=['GET'])\n    @token_required\n    def get_all_sources():\n        sources = Source.query.filter_by(pending=False).all()\n        return jsonify(\n            {'sources': [source.to_json() for source in sources]}), 200\n\n    @api.route('/sources/<source_uuid>', methods=['GET', 'DELETE'])\n    @token_required\n    def single_source(source_uuid):\n        if request.method == 'GET':\n            source = get_or_404(Source, source_uuid, column=Source.uuid)\n            return jsonify(source.to_json()), 200\n        elif request.method == 'DELETE':\n            source = get_or_404(Source, source_uuid, column=Source.uuid)\n            utils.delete_collection(source.filesystem_id)\n            return jsonify({'message': 'Source and submissions deleted'}), 200\n\n    @api.route('/sources/<source_uuid>/add_star', methods=['POST'])\n    @token_required\n    def add_star(source_uuid):\n        source = get_or_404(Source, source_uuid, column=Source.uuid)\n        utils.make_star_true(source.filesystem_id)\n        db.session.commit()\n        return jsonify({'message': 'Star added'}), 201\n\n    @api.route('/sources/<source_uuid>/remove_star', methods=['DELETE'])\n    @token_required\n    def remove_star(source_uuid):\n        source = get_or_404(Source, source_uuid, column=Source.uuid)\n        utils.make_star_false(source.filesystem_id)\n        db.session.commit()\n        return jsonify({'message': 'Star removed'}), 200\n\n    @api.route('/sources/<source_uuid>/flag', methods=['POST'])\n    @token_required\n    def flag(source_uuid):\n        source = get_or_404(Source, source_uuid,\n                            column=Source.uuid)\n        source.flagged = True\n        db.session.commit()\n        return jsonify({'message': 'Source flagged for reply'}), 200\n\n    @api.route('/sources/<source_uuid>/submissions', methods=['GET'])\n    @token_required\n    def all_source_submissions(source_uuid):\n        source = get_or_404(Source, source_uuid, column=Source.uuid)\n        return jsonify(\n            {'submissions': [submission.to_json() for\n                             submission in source.submissions]}), 200\n\n    @api.route('/sources/<source_uuid>/submissions/<submission_uuid>/download',  # noqa\n               methods=['GET'])\n    @token_required\n    def download_submission(source_uuid, submission_uuid):\n        source = get_or_404(Source, source_uuid, column=Source.uuid)\n        submission = get_or_404(Submission, submission_uuid,\n                                column=Submission.uuid)\n\n        # Mark as downloaded\n        submission.downloaded = True\n        db.session.commit()\n\n        return utils.serve_file_with_etag(source, submission.filename)\n\n    @api.route('/sources/<source_uuid>/replies/<reply_uuid>/download',\n               methods=['GET'])\n    @token_required\n    def download_reply(source_uuid, reply_uuid):\n        source = get_or_404(Source, source_uuid, column=Source.uuid)\n        reply = get_or_404(Reply, reply_uuid, column=Reply.uuid)\n\n        return utils.serve_file_with_etag(source, reply.filename)\n\n    @api.route('/sources/<source_uuid>/submissions/<submission_uuid>',\n               methods=['GET', 'DELETE'])\n    @token_required\n    def single_submission(source_uuid, submission_uuid):\n        if request.method == 'GET':\n            source = get_or_404(Source, source_uuid, column=Source.uuid)\n            submission = get_or_404(Submission, submission_uuid,\n                                    column=Submission.uuid)\n            return jsonify(submission.to_json()), 200\n        elif request.method == 'DELETE':\n            submission = get_or_404(Submission, submission_uuid,\n                                    column=Submission.uuid)\n            source = get_or_404(Source, source_uuid, column=Source.uuid)\n            utils.delete_file(source.filesystem_id, submission.filename,\n                              submission)\n            return jsonify({'message': 'Submission deleted'}), 200\n\n    @api.route('/sources/<source_uuid>/replies', methods=['GET', 'POST'])\n    @token_required\n    def all_source_replies(source_uuid):\n        if request.method == 'GET':\n            source = get_or_404(Source, source_uuid, column=Source.uuid)\n            return jsonify(\n                {'replies': [reply.to_json() for\n                             reply in source.replies]}), 200\n        elif request.method == 'POST':\n            source = get_or_404(Source, source_uuid,\n                                column=Source.uuid)\n            if request.json is None:\n                abort(400, 'please send requests in valid JSON')\n\n            if 'reply' not in request.json:\n                abort(400, 'reply not found in request body')\n\n            user = get_user_object(request)\n\n            data = json.loads(request.data)\n            if not data['reply']:\n                abort(400, 'reply should not be empty')\n\n            source.interaction_count += 1\n            try:\n                filename = current_app.storage.save_pre_encrypted_reply(\n                    source.filesystem_id,\n                    source.interaction_count,\n                    source.journalist_filename,\n                    data['reply'])\n            except NotEncrypted:\n                return jsonify(\n                    {'message': 'You must encrypt replies client side'}), 400\n\n            # issue #3918\n            filename = path.basename(filename)\n\n            reply = Reply(user, source, filename)\n\n            reply_uuid = data.get('uuid', None)\n            if reply_uuid is not None:\n                # check that is is parseable\n                try:\n                    UUID(reply_uuid)\n                except ValueError:\n                    abort(400, \"'uuid' was not a valid UUID\")\n                reply.uuid = reply_uuid\n\n            try:\n                db.session.add(reply)\n                db.session.add(source)\n                db.session.commit()\n            except IntegrityError as e:\n                db.session.rollback()\n                if 'UNIQUE constraint failed: replies.uuid' in str(e):\n                    abort(409, 'That UUID is already in use.')\n                else:\n                    raise e\n\n            return jsonify({'message': 'Your reply has been stored',\n                            'uuid': reply.uuid}), 201\n\n    @api.route('/sources/<source_uuid>/replies/<reply_uuid>',\n               methods=['GET', 'DELETE'])\n    @token_required\n    def single_reply(source_uuid, reply_uuid):\n        source = get_or_404(Source, source_uuid, column=Source.uuid)\n        reply = get_or_404(Reply, reply_uuid, column=Reply.uuid)\n        if request.method == 'GET':\n            return jsonify(reply.to_json()), 200\n        elif request.method == 'DELETE':\n            utils.delete_file(source.filesystem_id, reply.filename,\n                              reply)\n            return jsonify({'message': 'Reply deleted'}), 200\n\n    @api.route('/submissions', methods=['GET'])\n    @token_required\n    def get_all_submissions():\n        submissions = Submission.query.all()\n        return jsonify({'submissions': [submission.to_json() for\n                                        submission in submissions]}), 200\n\n    @api.route('/replies', methods=['GET'])\n    @token_required\n    def get_all_replies():\n        replies = Reply.query.all()\n        return jsonify(\n            {'replies': [reply.to_json() for reply in replies]}), 200\n\n    @api.route('/user', methods=['GET'])\n    @token_required\n    def get_current_user():\n        user = get_user_object(request)\n        return jsonify(user.to_json()), 200\n\n    def _handle_http_exception(error):\n        # Workaround for no blueprint-level 404/5 error handlers, see:\n        # https://github.com/pallets/flask/issues/503#issuecomment-71383286\n        response = jsonify({'error': error.name,\n                           'message': error.description})\n\n        return response, error.code\n\n    for code in default_exceptions:\n        api.errorhandler(code)(_handle_http_exception)\n\n    return api\n", "path": "securedrop/journalist_app/api.py"}]}