planka-register/app.py
Kumi 89825cf85c
feat: Enhance app config and user feedback
Introduced changes to `app.py` and templates to enhance application configuration options, improve user feedback mechanisms, and bolster security practices. Key updates include:

- Expanded Flask's configuration based on `settings.ini`, enabling `debug` mode and applying `ProxyFix` middleware conditionally to support reverse proxy setups.
- Extended the functionality to include dynamic footer links, sourced from the configuration file, across all relevant templates. This contributes to a more dynamic and maintainable web interface.
- Adjusted the rate limiting functionality from a 1-hour to a 1-day window, offering a more lenient and user-friendly request limitation system.
- Implemented an error handling flow for user creation in Planka, providing clearer feedback when password requirements are not met, thus enhancing the user signup experience.
- Added a new cron route for cleaning up stale requests from the database, aligning data retention practices with privacy concerns.

These changes aim to provide a more configurable, user-friendly, and secure application, addressing feedback and evolving requirements.
2024-04-26 09:45:43 +02:00

352 lines
8.8 KiB
Python

from flask import Flask, request, redirect, url_for, render_template, jsonify
from plankapy import Planka, User, InvalidToken
from flask_wtf import FlaskForm
from wtforms import StringField, SubmitField, PasswordField
from wtforms.validators import DataRequired, Email, ValidationError
from werkzeug.middleware.proxy_fix import ProxyFix
from configparser import ConfigParser
from random import SystemRandom
from typing import List, Tuple
import sqlite3
import smtplib
import uuid
import string
app = Flask(__name__, static_folder="static", template_folder="templates")
app.config["SECRET_KEY"] = "".join(
SystemRandom().choice("".join([string.ascii_letters, string.digits]))
for _ in range(50)
)
config = ConfigParser()
config.read("settings.ini")
if config.getboolean("App", "debug", fallback=False):
app.debug = True
if config.getboolean("App", "proxyfix", fallback=False):
app.wsgi_app = ProxyFix(app.wsgi_app)
def initialize_database():
conn = sqlite3.connect("db.sqlite3")
conn.execute(
"""
CREATE TABLE IF NOT EXISTS requests (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL,
token TEXT NOT NULL,
ip TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""
)
conn.close()
def rate_limit(request):
conn = sqlite3.connect("db.sqlite3")
cursor = conn.cursor()
cursor.execute(
"""
SELECT COUNT(*)
FROM requests
WHERE ip = ? AND created_at > datetime('now', '-1 day')
""",
(request.remote_addr,),
)
count = cursor.fetchone()[0]
conn.close()
return count >= config.getint("App", "rate_limit", fallback=5)
def get_mailserver():
if config.getboolean("SMTP", "ssl", fallback=True):
port = config.getint("SMTP", "port", fallback=465)
mailserver = smtplib.SMTP_SSL(config["SMTP"]["host"], port)
else:
port = config.getint("SMTP", "port", fallback=587)
mailserver = smtplib.SMTP(config["SMTP"]["host"], port)
if config.getboolean("SMTP", "starttls", fallback=True):
mailserver.starttls()
mailserver.login(config["SMTP"]["username"], config["SMTP"]["password"])
return mailserver
def get_footer_links() -> List[Tuple[str, str]]:
links = []
if "Footer" in config.sections():
for key in config["Footer"]:
links.append((key.capitalize(), config["Footer"][key]))
return links
def send_email(email, token):
mailserver = get_mailserver()
sender = config.get("SMTP", "from", fallback=config["SMTP"]["username"])
message = f"""
From: {sender}
To: {email}
Subject: {config['App']['name']} - Confirm your email address
Hi,
Thank you for registering with {config['App']['name']}! Please click the link below to confirm your email address:
https://{config['App']['host']}/confirm/{token}
If you did not register with {config['App']['name']}, please ignore this email.
Thanks,
The {config['App']['name']} Team
""".strip()
mailserver.sendmail(sender, email, message)
mailserver.quit()
def process_request(request):
email = request.form["email"]
conn = sqlite3.connect("db.sqlite3")
cursor = conn.cursor()
# Check if the email address is already in the database
cursor.execute(
"""
SELECT COUNT(*)
FROM requests
WHERE email = ?
""",
(email,),
)
count = cursor.fetchone()[0]
if count > 0:
return render_template(
"already_requested.html",
app=config["App"]["name"],
title="Already Requested",
subtitle="You have already requested access with this email address.",
footer_links=get_footer_links(),
)
token = str(uuid.uuid4())
cursor.execute(
"""
INSERT INTO requests (email, token, ip)
VALUES (?, ?, ?)
""",
(email, token, request.remote_addr),
)
conn.commit()
conn.close()
send_email(email, token)
return redirect(url_for("post_request"))
class EmailForm(FlaskForm):
email = StringField("Email", validators=[DataRequired(), Email()])
submit = SubmitField("Submit")
@app.route("/", methods=["GET", "POST"])
def start_request():
if rate_limit(request):
return render_template(
"rate_limit.html",
app=config["App"]["name"],
title="Rate Limited",
subtitle="You have reached the rate limit for requests. Please try again later.",
footer_links=get_footer_links(),
)
form = EmailForm()
if form.validate_on_submit():
return process_request(request)
return render_template(
"request.html",
app=config["App"]["name"],
title="Request Access",
subtitle="Please enter your email address to request access.",
form=form,
footer_links=get_footer_links(),
)
@app.route("/post_request")
def post_request():
return render_template(
"post_request.html",
app=config["App"]["name"],
title="Request Received",
subtitle="Your request has been received. Please check your email for further instructions.",
footer_links=get_footer_links(),
)
class SignupForm(FlaskForm):
email = StringField("Email")
name = StringField("Your Name", validators=[DataRequired()])
username = StringField("Username", validators=[DataRequired()])
password = PasswordField("Password", validators=[DataRequired()])
submit = SubmitField("Submit")
def validate_username(self, field):
planka = Planka(
url=config["Planka"]["url"],
username=config["Planka"]["username"],
password=config["Planka"]["password"],
)
users = User(planka)
try:
user = users.get(username=field.data)
if user:
raise ValidationError(f"User with username {field.data} already exists")
except InvalidToken:
# This error *should* be specific at this point, but I still don't trust it
pass
@app.route("/confirm/<token>", methods=["GET", "POST"])
def confirm_request(token):
conn = sqlite3.connect("db.sqlite3")
cursor = conn.cursor()
cursor.execute(
"""
SELECT email
FROM requests
WHERE token = ?
""",
(token,),
)
row = cursor.fetchone()
if row is None:
return render_template(
"unknown.html",
app=config["App"]["name"],
title="Invalid Token",
subtitle="The token you provided is invalid.",
footer_links=get_footer_links(),
)
email = row[0]
form = SignupForm()
if form.validate_on_submit():
planka = Planka(
url=config["Planka"]["url"],
username=config["Planka"]["username"],
password=config["Planka"]["password"],
)
users = User(planka)
new_user = users.build(
username=form.username.data,
name=form.name.data,
password=form.password.data,
email=email,
)
try:
users.create(new_user)
except InvalidToken:
form.password.errors.append(
"Your password did not meet Planka's requirements. Please try again."
)
return render_template(
"signup.html",
app=config["App"]["name"],
title="Complete Signup",
subtitle="Please confirm your email address by filling out the form below.",
email=email,
form=form,
footer_links=get_footer_links(),
)
cursor.execute(
"""
DELETE FROM requests
WHERE token = ?
""",
(token,),
)
conn.commit()
conn.close()
return redirect(url_for("post_signup"))
return render_template(
"signup.html",
app=config["App"]["name"],
title="Complete Signup",
subtitle="Please confirm your email address by filling out the form below.",
email=email,
form=form,
footer_links=get_footer_links(),
)
@app.route("/post_signup")
def post_signup():
return render_template(
"post_signup.html",
app=config["App"]["name"],
title="Signup Complete",
subtitle="Your account has been created. You may now log in.",
planka=config["Planka"]["url"],
footer_links=get_footer_links(),
)
@app.route("/cron")
def cron():
conn = sqlite3.connect("db.sqlite3")
cursor = conn.cursor()
cursor.execute(
"""
DELETE FROM requests
WHERE created_at < datetime('now', '-2 day')
"""
)
conn.commit()
conn.close()
return jsonify({"status": "ok"})
initialize_database()