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()" gunicorn -b 0.0.0.0:80 -b 0.0.0.0:443 "undercover:create_app()"
else else
echo "Starting local dev server..." echo "Starting local dev server..."
export FLASK_ENV=development
export FLASK_APP=undercover export FLASK_APP=undercover
flask run --host=0.0.0.0 flask --debug run --host=0.0.0.0
fi fi

View File

@ -1,4 +1,7 @@
import os import os
import sys
import threading
import types
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Optional 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') db_available = host and db_name and port and db_user and os.environ.get('UNDERCOVER_POSTGRES_PASSWORD')
if db_available:
def connect(): def connect():
return psycopg.connect( return psycopg.connect(
host=host, host=host,
dbname=db_name, dbname=db_name,
port=port, port=port,
user=db_user, user=db_user,
password=os.environ.get('UNDERCOVER_POSTGRES_PASSWORD')) 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 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): def login(user_email: str, password: str):
@ -120,6 +147,9 @@ def __get_user(email: str) -> Optional[UserWithHash]:
return None return None
RESET_TIME = timedelta(minutes=-1 * 15)
def initiate_password_reset(email: str) -> Optional[UUID]: def initiate_password_reset(email: str) -> Optional[UUID]:
user = get_user(email) user = get_user(email)
if not user: 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())", "INSERT INTO resets(user_id, id, reset_time) VALUES (%s, %s, NOW())",
(user.id, reset_id) (user.id, reset_id)
) )
threading.Timer(RESET_TIME.total_seconds(), delete_reset_row, [reset_id]).start()
con.commit() con.commit()
return reset_id 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): def complete_reset(reset_id: str, new_password: str):

View File

@ -1,10 +1,19 @@
from mailjet_rest import Client import json
import os import os
import sys
api_key = os.environ['MAILJET_API_KEY'] from mailjet_rest import Client
api_secret = os.environ['MAILJET_SECRET_KEY']
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): def send_password_reset(to_email: str, reset_link: str):
@ -15,23 +24,28 @@ def send_password_reset(to_email: str, reset_link: str):
"Email": "donotreply@undercover.cafe", "Email": "donotreply@undercover.cafe",
"Name": "UnderCover" "Name": "UnderCover"
}, },
"To": [ "To": [{"Email": to_email}],
{
"Email": to_email
# "Name": "Sage"
}
],
"Subject": "UnderCover - Password Reset", "Subject": "UnderCover - Password Reset",
"TextPart": "Complete your UnderCover password reset by visiting " + reset_link, "TextPart": f"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, "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" "CustomID": "PasswordResetLink"
} }
] ]
} }
if mailjet:
result = mailjet.send.create(data=data) result = mailjet.send.create(data=data)
return 200 <= result.status_code <= 299 return 200 <= result.status_code <= 299
print(json.JSONEncoder(indent=2).encode(data))
return True
if __name__ == "__main__": if __name__ == "__main__":
send_password_reset('sage@sagev.space', 'https://sagev.space/') send_password_reset('sage@sagev.space', 'https://sagev.space/')

View File

@ -4,7 +4,7 @@ import os
import subprocess import subprocess
from dataclasses import dataclass from dataclasses import dataclass
from flask import send_from_directory from flask import send_from_directory, Response
def get_unique(): def get_unique():
@ -48,7 +48,11 @@ class CLData:
("body", self.body), ("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 import threading
unique_id = get_unique() unique_id = get_unique()
unique_file = output_dir + unique_id + ".tex" unique_file = output_dir + unique_id + ".tex"
@ -85,19 +89,19 @@ class CLData:
else: else:
output_file += ".pdf" output_file += ".pdf"
return (send_from_directory( return send_from_directory(
output_dir, output_dir,
output_file, output_file,
download_name=self.username.replace(" ", "") + "_CoverLetter.pdf", download_name=self.username.replace(" ", "") + "_CoverLetter.pdf",
as_attachment=True as_attachment=True
), None) )
else: else:
print(build_text + "[FAIL]") print(build_text + "[FAIL]")
# Collect output but delete boilerplate text # Collect output but delete boilerplate text
errors = list(map(str.strip, result.stdout.split("\n"))) errors = list(map(str.strip, result.stdout.split("\n")))
del errors[:13] del errors[:13]
del errors[-2:] del errors[-2:]
return None, errors raise ValueError(errors)
def cleanup(unique): 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)): 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) return render_index(error="Failed to send reset email. Please try again later.", status=500)
elif existing_reset_id: elif existing_reset_id:
# TODO: Eventually remove db entry whether or not link is clicked
new_password = request.form['password'] new_password = request.form['password']
if not db.complete_reset(existing_reset_id, new_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) 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']) @writing_blueprint.route('/', methods=['POST'])
def index_post(): def generate_pdf():
form = CLForm(request.form) form = CLForm(request.form)
if form.validate(): if form.validate():
selected_letter = form.letterName.data or '1' selected_letter = form.letterName.data or '1'
@ -243,12 +242,15 @@ def index_post():
# TODO: Support title editing # TODO: Support title editing
db.edit_letter(letter.id, letter.title, letter_json) db.edit_letter(letter.id, letter.title, letter_json)
(resp, errors) = data.generate_pdf() try:
if errors: resp = data.generate_pdf()
resp = render_index(form=form, letter_errors=errors) except ValueError as e:
resp = render_index(form=form, letter_errors=e.args[0])
# Save entered data as cookies on user's machine # Save entered data as cookies on user's machine
for pair in data.get_pairs(): for pair in data.get_pairs():
resp.set_cookie(pair[0], urllib.parse.quote(pair[1])) resp.set_cookie(pair[0], urllib.parse.quote(pair[1]))
return resp return resp
return render_index(form=form) return render_index(form=form)