diff --git a/README.md b/README.md index b8de0da..f2e4c59 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ After it prints `ADMIN_PASSWORD_ROTATED_MFA_RESET`, sign in as `admin` with the ## Email and password recovery -Registration now requires an email address and a confirmation link before the account can sign in. Members can change their email address from the navigation; the current address remains active until the link sent to the new address is confirmed. Verification and password-reset links expire after 24 hours and one hour respectively, and can only be used once. Administrators can send the same recovery email to any approved member from **Accounts**. +Registration requires a real name, email address, and confirmation link before the account can sign in. The real name is used as the published-by name, while existing accounts continue to use their handle until they set a profile name. Members can change their email address from the navigation; the current address remains active until the link sent to the new address is confirmed. Verification and password-reset links expire after 24 hours and one hour respectively, and can only be used once. Administrators can send the same recovery email to any approved member from **Accounts**. All browser POST forms are protected by server-validated CSRF tokens. diff --git a/TODO.md b/TODO.md index 1b2b20b..87569a9 100644 --- a/TODO.md +++ b/TODO.md @@ -14,5 +14,5 @@ - [ ] Add code snippets to long-form stories. - [x] Allow users to change their email addresses. - [x] Add user email verification for registration and email-address changes. -- [ ] Add user real names and use them as the published-by name. +- [x] Add user real names and use them as the published-by name. - [ ] Add ability to hide posts from unregistered users. \ No newline at end of file diff --git a/app.py b/app.py index 4abcb23..c4a650f 100644 --- a/app.py +++ b/app.py @@ -121,6 +121,7 @@ def init_db(): "mfa_enabled": "ALTER TABLE users ADD COLUMN mfa_enabled INTEGER NOT NULL DEFAULT 0", "email": "ALTER TABLE users ADD COLUMN email TEXT", "email_verified": "ALTER TABLE users ADD COLUMN email_verified INTEGER NOT NULL DEFAULT 0", + "real_name": "ALTER TABLE users ADD COLUMN real_name TEXT", } post_columns = {row["name"] for row in db.execute("PRAGMA table_info(posts)")} for column, statement in migrations.items(): @@ -252,6 +253,14 @@ def valid_email(email): return re.fullmatch(r"[^\s@]+@[^\s@]+\.[^\s@]+", email) is not None +def normalize_real_name(real_name): + return " ".join(real_name.split()) + + +def valid_real_name(real_name): + return 2 <= len(real_name) <= 100 and all(character.isalnum() or character in " .'-" for character in real_name) + + def create_reset_token(user_id): token = token_urlsafe(32) now = datetime.now(timezone.utc) @@ -321,14 +330,14 @@ def send_verification_email(user, email_address, token_type): def inject_current_user(): user = None if "user_id" in session: - user = get_db().execute("SELECT id, username, role FROM users WHERE id = ?", (session["user_id"],)).fetchone() + user = get_db().execute("SELECT id, username, real_name, role FROM users WHERE id = ?", (session["user_id"],)).fetchone() return {"current_user": user} @app.route("/") def index(): posts = get_db().execute( - """SELECT posts.*, users.username FROM posts JOIN users ON users.id = posts.author_id + """SELECT posts.*, users.username, users.real_name FROM posts JOIN users ON users.id = posts.author_id ORDER BY posts.created_at DESC""" ).fetchall() return render_template("index.html", posts=posts) @@ -337,7 +346,7 @@ def index(): @app.route("/post/") def post(slug): article = get_db().execute( - """SELECT posts.*, users.username FROM posts JOIN users ON users.id = posts.author_id + """SELECT posts.*, users.username, users.real_name FROM posts JOIN users ON users.id = posts.author_id WHERE posts.slug = ?""", (slug,), ).fetchone() @@ -356,10 +365,13 @@ def uploaded_image(filename): def register(): if request.method == "POST": username = request.form.get("username", "").strip().lower() + real_name = normalize_real_name(request.form.get("real_name", "")) email = request.form.get("email", "").strip().lower() password = request.form.get("password", "") if not re.fullmatch(r"[a-z0-9_-]{3,32}", username): flash("Use 3-32 lowercase letters, numbers, hyphens, or underscores.", "error") + elif not valid_real_name(real_name): + flash("Enter a real name using 2-100 letters, numbers, spaces, hyphens, apostrophes, or periods.", "error") elif len(password) < 10: flash("Choose a password with at least 10 characters.", "error") elif not valid_email(email): @@ -367,8 +379,8 @@ def register(): else: try: cursor = get_db().execute( - "INSERT INTO users (username, email, password_hash, created_at, is_approved) VALUES (?, ?, ?, ?, 0)", - (username, email, generate_password_hash(password), datetime.now(timezone.utc).isoformat()), + "INSERT INTO users (username, real_name, email, password_hash, created_at, is_approved) VALUES (?, ?, ?, ?, ?, 0)", + (username, real_name, email, generate_password_hash(password), datetime.now(timezone.utc).isoformat()), ) get_db().commit() user = {"id": cursor.lastrowid, "username": username} @@ -509,6 +521,22 @@ def change_password(): return render_template("password_form.html", mode="change") +@app.route("/account/profile", methods=("GET", "POST")) +@login_required +def change_profile(): + user = get_db().execute("SELECT real_name FROM users WHERE id = ?", (session["user_id"],)).fetchone() + if request.method == "POST": + real_name = normalize_real_name(request.form.get("real_name", "")) + if not valid_real_name(real_name): + flash("Enter a real name using 2-100 letters, numbers, spaces, hyphens, apostrophes, or periods.", "error") + else: + get_db().execute("UPDATE users SET real_name = ? WHERE id = ?", (real_name, session["user_id"])) + get_db().commit() + flash("Profile updated.", "success") + return redirect(url_for("index")) + return render_template("profile_form.html", real_name=user["real_name"] or "") + + @app.route("/account/email", methods=("GET", "POST")) @login_required def change_email(): @@ -599,7 +627,7 @@ def mfa_verify(): @admin_required def admin_users(): users = get_db().execute( - "SELECT id, username, email, email_verified, created_at, is_approved, mfa_enabled, role FROM users ORDER BY is_approved, created_at DESC" + "SELECT id, username, real_name, email, email_verified, created_at, is_approved, mfa_enabled, role FROM users ORDER BY is_approved, created_at DESC" ).fetchall() return render_template("admin_users.html", users=users) diff --git a/data/eternity.db b/data/eternity.db index 1cbbb31..1a8a2bc 100644 Binary files a/data/eternity.db and b/data/eternity.db differ diff --git a/templates/admin_users.html b/templates/admin_users.html index 52f3797..b431b9d 100644 --- a/templates/admin_users.html +++ b/templates/admin_users.html @@ -7,7 +7,7 @@
HANDLE / EMAILREGISTEREDMFASTATUS
{% for user in users %}
- {{ user.username }}{% if user.role == 'admin' %} ADMIN{% endif %} + {{ user.real_name or user.username }}{% if user.role == 'admin' %} ADMIN{% endif %} {{ user.created_at[:10] }} {{ 'ENABLED' if user.mfa_enabled else 'PENDING' }} {% if user.is_approved and user.role == 'member' %}
{% elif user.is_approved %}APPROVED{% else %}
{% endif %}
diff --git a/templates/auth.html b/templates/auth.html index d2b5034..efbef91 100644 --- a/templates/auth.html +++ b/templates/auth.html @@ -6,6 +6,7 @@
+ {% if mode == 'register' %}{% endif %} {% if mode == 'register' %}{% endif %} diff --git a/templates/base.html b/templates/base.html index d0d0678..89a22a1 100644 --- a/templates/base.html +++ b/templates/base.html @@ -21,9 +21,10 @@ {% if current_user %} Write {% if current_user.role == 'admin' %}Accounts{% endif %} + Profile Email Password - {{ current_user.username }} + {{ current_user.real_name or current_user.username }}
{% else %} Sign in diff --git a/templates/index.html b/templates/index.html index c40d224..f6b5d7a 100644 --- a/templates/index.html +++ b/templates/index.html @@ -16,7 +16,7 @@

{{ post.title }}

{{ post.excerpt }}

-
BY {{ post.username }}→
+
BY {{ post.real_name or post.username }}→
{% endfor %}
diff --git a/templates/post.html b/templates/post.html index e839c5c..f739cd8 100644 --- a/templates/post.html +++ b/templates/post.html @@ -1,7 +1,7 @@ {% extends 'base.html' %} {% block title %}{{ post.title }} | Eternity Project{% endblock %} {% block content %} -

{{ post.title }}

{{ post.excerpt }}

{% if post.image_filename %}{% endif %}
{% for kind, content in blocks %}{% if kind == 'text' %}
{{ content }}
{% else %}
{% if content.caption %}
{{ content.caption }}
{% endif %}
{% endif %}{% endfor %}
{% if current_user and current_user.id == post.author_id %}Edit post{% endif %}
+

{{ post.title }}

{{ post.excerpt }}

{% if post.image_filename %}{% endif %}
{% for kind, content in blocks %}{% if kind == 'text' %}
{{ content }}
{% else %}
{% if content.caption %}
{{ content.caption }}
{% endif %}
{% endif %}{% endfor %}
{% if current_user and current_user.id == post.author_id %}Edit post{% endif %}
{% endblock %} diff --git a/templates/profile_form.html b/templates/profile_form.html new file mode 100644 index 0000000..baea609 --- /dev/null +++ b/templates/profile_form.html @@ -0,0 +1,12 @@ +{% extends 'base.html' %} +{% block title %}Profile | Eternity Project{% endblock %} +{% block content %} +
+
PROFILE

Set your
byline.

Your real name appears as the published-by name on your posts.

+
+ + + +
+
+{% endblock %} \ No newline at end of file diff --git a/test_email_verification.py b/test_email_verification.py index a144aa7..1b57aa4 100644 --- a/test_email_verification.py +++ b/test_email_verification.py @@ -37,7 +37,7 @@ class EmailVerificationTestCase(unittest.TestCase): with patch("app.smtplib.SMTP") as smtp: response = self.client.post( "/register", - data={"username": "newmember", "email": "new@example.com", "password": "secure-password"}, + data={"username": "newmember", "real_name": "New Member", "email": "new@example.com", "password": "secure-password"}, ) self.assertEqual(response.status_code, 302) @@ -85,6 +85,40 @@ class EmailVerificationTestCase(unittest.TestCase): with application.app.app_context(): self.assertEqual(application.get_db().execute("SELECT email FROM users WHERE id = ?", (user_id,)).fetchone()[0], "new@example.com") + def test_profile_name_is_used_for_byline_with_handle_fallback(self): + with application.app.app_context(): + db = application.get_db() + named_author = db.execute( + """INSERT INTO users (username, email, email_verified, password_hash, created_at, is_approved, role, mfa_enabled) + VALUES (?, ?, 1, ?, ?, 1, 'member', 1)""", + ("named", "named@example.com", generate_password_hash("secure-password"), "2026-09-01T00:00:00+00:00"), + ).lastrowid + legacy_author = db.execute( + """INSERT INTO users (username, email, email_verified, password_hash, created_at, is_approved, role, mfa_enabled) + VALUES (?, ?, 1, ?, ?, 1, 'member', 1)""", + ("legacy", "legacy@example.com", generate_password_hash("secure-password"), "2026-09-01T00:00:00+00:00"), + ).lastrowid + db.execute( + """INSERT INTO posts (title, slug, excerpt, body, category, author_id, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)""", + ("Named post", "named-post", "Named excerpt", "Named body", "Code", named_author, "2026-09-01T00:00:00+00:00", "2026-09-01T00:00:00+00:00"), + ) + db.execute( + """INSERT INTO posts (title, slug, excerpt, body, category, author_id, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)""", + ("Legacy post", "legacy-post", "Legacy excerpt", "Legacy body", "Code", legacy_author, "2026-09-01T00:00:00+00:00", "2026-09-01T00:00:00+00:00"), + ) + db.commit() + with self.client.session_transaction() as session: + session["user_id"] = named_author + session["mfa_verified"] = True + + response = self.client.post("/account/profile", data={"real_name": "Ada Lovelace"}) + self.assertEqual(response.status_code, 302) + self.assertIn(b"BY Ada Lovelace", self.client.get("/").data) + self.assertIn(b"WRITTEN BY Ada Lovelace", self.client.get("/post/named-post").data) + self.assertIn(b"BY legacy", self.client.get("/").data) + if __name__ == "__main__": unittest.main() \ No newline at end of file