diff --git a/start b/start index 8296cd8..ff1a855 100755 --- a/start +++ b/start @@ -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 diff --git a/undercover/db.py b/undercover/db.py index e1bc625..b0b85f5 100644 --- a/undercover/db.py +++ b/undercover/db.py @@ -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): diff --git a/undercover/email.py b/undercover/email.py index 3ceb473..f599391 100644 --- a/undercover/email.py +++ b/undercover/email.py @@ -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""" +

+ Click here to complete your UnderCover password reset +

+
+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": "

Click here to complete your UnderCover password reset


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__": diff --git a/undercover/pdf_builder.py b/undercover/pdf_builder.py index cdc9bb0..96a79d9 100644 --- a/undercover/pdf_builder.py +++ b/undercover/pdf_builder.py @@ -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): diff --git a/undercover/routes.py b/undercover/routes.py index 77742ed..f90eb89 100644 --- a/undercover/routes.py +++ b/undercover/routes.py @@ -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)