Add basic fallbacks for missing db/email creds.

Fallbacks may eventually warrant their own file.
Use f-strings to format password-reset emails.
Automatically delete email-reset rows after 15 minutes.
 - Cute, but a proper vacuum would likely be more robust.
Remove deprecated FLASK_ENV use.
Use exceptions in generate_pdf()
This commit is contained in:
Sage Vaillancourt 2022-09-26 17:00:21 -04:00
parent 7800c24f99
commit d358c55017
5 changed files with 96 additions and 42 deletions

3
start
View File

@ -18,7 +18,6 @@ if [[ "$1" == "prod" ]]; then
gunicorn -b 0.0.0.0:80 -b 0.0.0.0:443 "undercover:create_app()"
else
echo "Starting local dev server..."
export FLASK_ENV=development
export FLASK_APP=undercover
flask run --host=0.0.0.0
flask --debug run --host=0.0.0.0
fi

View File

@ -1,4 +1,7 @@
import os
import sys
import threading
import types
from dataclasses import dataclass
from datetime import datetime, timedelta
from typing import Optional
@ -35,14 +38,38 @@ db_user = os.environ.get('UNDERCOVER_POSTGRES_USER')
db_available = host and db_name and port and db_user and os.environ.get('UNDERCOVER_POSTGRES_PASSWORD')
if db_available:
def connect():
return psycopg.connect(
host=host,
dbname=db_name,
port=port,
user=db_user,
password=os.environ.get('UNDERCOVER_POSTGRES_PASSWORD'))
else:
sys.stderr.write('Database login not configured: DB access is disabled.\n')
sys.stderr.write(' To enable, ensure UNDERCOVER_POSTGRES_{HOST,DBNAME,PORT,USER,PASSWORD} are set.\n')
def connect():
return psycopg.connect(
host=host,
dbname=db_name,
port=port,
user=db_user,
password=os.environ.get('UNDERCOVER_POSTGRES_PASSWORD'))
def connect():
return MockConnection()
class MockConnection:
mock_cursor = types.SimpleNamespace()
mock_cursor.execute = lambda *a: ()
mock_cursor.fetchone = lambda *a: None
mock_cursor.fetchall = lambda *a: []
def __enter__(self, *a):
return self
def __exit__(self, *a):
pass
def cursor(self):
return self.mock_cursor
def commit(self, *a):
pass
def login(user_email: str, password: str):
@ -120,6 +147,9 @@ def __get_user(email: str) -> Optional[UserWithHash]:
return None
RESET_TIME = timedelta(minutes=-1 * 15)
def initiate_password_reset(email: str) -> Optional[UUID]:
user = get_user(email)
if not user:
@ -131,11 +161,16 @@ def initiate_password_reset(email: str) -> Optional[UUID]:
"INSERT INTO resets(user_id, id, reset_time) VALUES (%s, %s, NOW())",
(user.id, reset_id)
)
threading.Timer(RESET_TIME.total_seconds(), delete_reset_row, [reset_id]).start()
con.commit()
return reset_id
RESET_TIME = timedelta(minutes=-1 * 15)
def delete_reset_row(reset_id: UUID):
with connect() as con:
cur = con.cursor()
cur.execute("DELETE FROM resets WHERE id = %s", (reset_id,))
con.commit()
def complete_reset(reset_id: str, new_password: str):

View File

@ -1,36 +1,50 @@
from mailjet_rest import Client
import json
import os
import sys
api_key = os.environ['MAILJET_API_KEY']
api_secret = os.environ['MAILJET_SECRET_KEY']
from mailjet_rest import Client
mailjet = Client(auth=(api_key, api_secret), version='v3.1')
api_key = os.environ.get('MAILJET_API_KEY')
api_secret = os.environ.get('MAILJET_SECRET_KEY')
has_mailjet_keys = api_key and api_secret
mailjet = Client(auth=(api_key, api_secret), version='v3.1') if has_mailjet_keys else None
if not mailjet:
sys.stderr.write('Mailjet keys not configured: email access is disabled.\n')
sys.stderr.write(' Emails will be printed to the console.\n')
sys.stderr.write(' To enable, ensure MAILJET_API_KEY and MAILJET_SECRET_KEY are set\n')
def send_password_reset(to_email: str, reset_link: str):
data = {
'Messages': [
{
"From": {
"Email": "donotreply@undercover.cafe",
"Name": "UnderCover"
},
"To": [
'Messages': [
{
"Email": to_email
# "Name": "Sage"
"From": {
"Email": "donotreply@undercover.cafe",
"Name": "UnderCover"
},
"To": [{"Email": to_email}],
"Subject": "UnderCover - Password Reset",
"TextPart": f"Complete your UnderCover password reset by visiting {reset_link}",
"HTMLPart": f"""
<h3>
<a href='{reset_link}'>Click here to complete your UnderCover password reset</a>
</h3>
<br />
Or copy and paste this link into your browser: {reset_link}
""",
"CustomID": "PasswordResetLink"
}
],
"Subject": "UnderCover - Password Reset",
"TextPart": "Complete your UnderCover password reset by visiting " + reset_link,
"HTMLPart": "<h3><a href='" + reset_link + "'>Click here to complete your UnderCover password reset</a></h3><br />Or copy and paste this link into your browser: " + reset_link,
"CustomID": "PasswordResetLink"
}
]
]
}
result = mailjet.send.create(data=data)
return 200 <= result.status_code <= 299
if mailjet:
result = mailjet.send.create(data=data)
return 200 <= result.status_code <= 299
print(json.JSONEncoder(indent=2).encode(data))
return True
if __name__ == "__main__":

View File

@ -4,7 +4,7 @@ import os
import subprocess
from dataclasses import dataclass
from flask import send_from_directory
from flask import send_from_directory, Response
def get_unique():
@ -48,7 +48,11 @@ class CLData:
("body", self.body),
]
def generate_pdf(self):
def generate_pdf(self) -> Response:
"""
:return: Response when successful
:raise ValueError: e.args[0] is a list of error strings, if generation fails
"""
import threading
unique_id = get_unique()
unique_file = output_dir + unique_id + ".tex"
@ -85,19 +89,19 @@ class CLData:
else:
output_file += ".pdf"
return (send_from_directory(
return send_from_directory(
output_dir,
output_file,
download_name=self.username.replace(" ", "") + "_CoverLetter.pdf",
as_attachment=True
), None)
)
else:
print(build_text + "[FAIL]")
# Collect output but delete boilerplate text
errors = list(map(str.strip, result.stdout.split("\n")))
del errors[:13]
del errors[-2:]
return None, errors
raise ValueError(errors)
def cleanup(unique):

View File

@ -164,7 +164,6 @@ def reset_password():
if not email.send_password_reset(email_address, 'https://undercover.cafe/reset?id=' + str(reset_id)):
return render_index(error="Failed to send reset email. Please try again later.", status=500)
elif existing_reset_id:
# TODO: Eventually remove db entry whether or not link is clicked
new_password = request.form['password']
if not db.complete_reset(existing_reset_id, new_password):
return render_index(error="Password reset failed. Your reset link may have expired.", status=500)
@ -215,7 +214,7 @@ def git_update():
@writing_blueprint.route('/', methods=['POST'])
def index_post():
def generate_pdf():
form = CLForm(request.form)
if form.validate():
selected_letter = form.letterName.data or '1'
@ -243,12 +242,15 @@ def index_post():
# TODO: Support title editing
db.edit_letter(letter.id, letter.title, letter_json)
(resp, errors) = data.generate_pdf()
if errors:
resp = render_index(form=form, letter_errors=errors)
try:
resp = data.generate_pdf()
except ValueError as e:
resp = render_index(form=form, letter_errors=e.args[0])
# Save entered data as cookies on user's machine
for pair in data.get_pairs():
resp.set_cookie(pair[0], urllib.parse.quote(pair[1]))
return resp
return render_index(form=form)