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:
parent
7800c24f99
commit
d358c55017
3
start
3
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()"
|
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
|
||||||
|
|
|
@ -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,7 +38,7 @@ 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,
|
||||||
|
@ -43,6 +46,30 @@ def connect():
|
||||||
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):
|
||||||
|
|
|
@ -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/')
|
||||||
|
|
|
@ -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):
|
||||||
|
|
|
@ -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)
|
||||||
|
|
Loading…
Reference in New Issue