From 9ca40e1814baee727d3cf44e7c693d1fe1f8810f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lera=20Elvo=C3=A9?= Date: Fri, 22 May 2026 00:39:27 +0300 Subject: [PATCH] add settings routes --- app/auth.py | 17 ++- app/models.py | 24 ++++ app/routes/users.py | 197 ++++++++++++++++++++++++++++-- app/templates/common/macros.html | 47 ++++--- app/templates/threads/thread.html | 2 +- app/templates/users/settings.html | 80 ++++++++++++ app/templates/users/sign_up.html | 4 +- data/static/css/style.css | 54 +++++--- 8 files changed, 375 insertions(+), 50 deletions(-) create mode 100644 app/templates/users/settings.html diff --git a/app/auth.py b/app/auth.py index 8db92b2..bfb88ef 100644 --- a/app/auth.py +++ b/app/auth.py @@ -66,6 +66,13 @@ def revoke_session(user_id): sess.delete() session.clear() +def revoke_all_sessions(user_id): + if not is_logged_in(): + return + + Sessions.revoke_all(user_id) + session.clear() + def parse_username(username: str) -> Tuple[str, str]: """first is the unmodified name/display name, second is username""" if len(username) < 3: @@ -77,6 +84,15 @@ def parse_username(username: str) -> Tuple[str, str]: invalid_regex = r'[^a-zA-Z0-9_-]' return re.sub(invalid_regex, '_', username.lower())[:24], username +def parse_display_name(display_name: str) -> str: + if len(display_name) == 0: + return display_name + invalid_regex = r'[@<>&]' + res = re.sub(invalid_regex, '_', display_name)[:50] + while len(res) < 3: + res += '_' + return res + def is_password_valid(password: str) -> bool: return re.match(r'^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[\W_])(?!.*\s).{10,255}$', password) is not None @@ -130,4 +146,3 @@ def csrf_verified(view_func): return view_func(*args, **kwargs) return wrapper - diff --git a/app/models.py b/app/models.py index 572e2ad..d5ccdcc 100644 --- a/app/models.py +++ b/app/models.py @@ -127,6 +127,24 @@ class Users(Model): res = db.fetch_one(q, self.id) return res["c"] or 0 + def set_signature(self, content:str, language: str = 'babycode'): + if not content: + self.update({ + 'signature_original_markup': '', + 'signature_rendered': '', + 'signature_format_version': None, + }) + return + + from .lib.babycode import babycode_to_html, BABYCODE_VERSION + from .constants import SIG_BANNED_TAGS + signature_rendered = babycode_to_html(content, SIG_BANNED_TAGS).result + self.update({ + 'signature_original_markup': content, + 'signature_rendered': signature_rendered, + 'signature_format_version': BABYCODE_VERSION, + }) + class Topics(Model): table = 'topics' @@ -396,6 +414,12 @@ class PostHistory(Model): class Sessions(Model): table = 'sessions' + @classmethod + def revoke_all(cls, user_id: int): + qb = db.QueryBuilder(cls.table).where({'user_id': user_id}) + sql, params = qb.build_delete() + db.execute(sql, *params) + class Avatars(Model): table = 'avatars' diff --git a/app/routes/users.py b/app/routes/users.py index 839b993..447ce8c 100644 --- a/app/routes/users.py +++ b/app/routes/users.py @@ -1,19 +1,60 @@ -from flask import Blueprint, redirect, url_for, render_template, request, session, abort +from flask import ( + Blueprint, redirect, url_for, + render_template, request, session, + abort, flash, current_app + ) from functools import wraps -import time - +from secrets import compare_digest as compare_timesafe +from wand.image import Image +from wand.exceptions import WandException from ..auth import ( digest, verify, create_session, is_logged_in, parse_username, is_password_valid, - login_required, revoke_session, get_active_user + login_required, revoke_session, get_active_user, + parse_display_name, revoke_all_sessions ) -from ..models import Users, Posts, Reactions, Threads -from ..constants import PermissionLevel -from secrets import compare_digest as compare_timesafe +from ..models import Users, Posts, Reactions, Threads, Avatars +from ..constants import PermissionLevel, InfoboxKind +from ..util import get_form_checkbox import math +import os +import time + +AVATAR_MAX_SIZE = 1000 * 1000 # 1MB bp = Blueprint('users', __name__, url_prefix='/users/') +def validate_and_create_avatar(input_image, filename): + try: + with Image(blob=input_image) as img: + if hasattr(img, 'sequence') and len(img.sequence) > 1: + img = Image(image=img.sequence[0]) + img.strip() + img.gravity = 'center' + + width, height = img.width, img.height + min_dim = min(width, height) + if min_dim > 256: + ratio = 256.0 / min_dim + new_width = int(width * ratio) + new_height = int(height * ratio) + img.resize(new_width, new_height) + + width, height = img.width, img.height + crop_size = min(width, height) + x_offset = (width - crop_size) // 2 + y_offset = (height - crop_size) // 2 + img.crop(left=x_offset, top=y_offset, + width=crop_size, height=crop_size) + + img.resize(256, 256) + img.format = 'webp' + img.compression_quality = 85 + img.save(filename=filename) + return True + except WandException: + return False + def redirect_if_logged_in(destination='topics.all_topics'): def decorator(view_func): @wraps(view_func) @@ -36,6 +77,15 @@ def redirect_to_own(view_func): return view_func(username, *args, **kwargs) return wrapper +def user_required(view_func): + @wraps(view_func) + def wrapper(username, *args, **kwargs): + user = get_active_user() + if user.is_guest(): + abort(403) + + return view_func(username, *args, **kwargs) + return wrapper @bp.get('/log-in/') @redirect_if_logged_in() @@ -106,7 +156,7 @@ def sign_up_post(): if username_pair[0] != username_pair[1]: user.update({ - 'display_name': username_pair[1] + 'display_name': parse_display_name(username_pair[1]) }) session['remember'] = request.form.get('remember') == 'on' @@ -182,7 +232,135 @@ def comments(username): @login_required @redirect_to_own def settings(username): - username = username.lower() + user = get_active_user() + sort_by = session.get('sort_by', 'activity') + return render_template( + 'users/settings.html', user=user, + sort_by=sort_by + ) + +@bp.post('//settings/set-avatar') +@login_required +@user_required +@redirect_to_own +def set_avatar(username): + user = get_active_user() + return_to = redirect(url_for('.settings', username=user.username)) + if 'avatar' not in request.files: + abort(400) + + avi_file = request.files['avatar'] + + if avi_file.filename == '': + abort(400) + + avi_file.seek(0, os.SEEK_END) + file_size = avi_file.tell() + avi_file.seek(0, os.SEEK_SET) + + if file_size > AVATAR_MAX_SIZE: + flash('Your avatar must be 1MB or less.', InfoboxKind.ERROR) + return return_to + + avi_bytes = avi_file.read() + now = int(time.time()) + filename = f'u{user.id}d{now}.webp' + output_path = os.path.join(current_app.config['AVATAR_UPLOAD_PATH'], filename) + proxied_filename = f'/static/avatars/{filename}' + + res = validate_and_create_avatar(avi_bytes, output_path) + if res: + flash('Avatar updated.', InfoboxKind.INFO) + avatar = Avatars.create({ + 'file_path': proxied_filename, + 'uploaded_at': now, + }) + old_avatar = Avatars.find({'id': user.avatar_id}) + user.update({'avatar_id': avatar.id}) + if int(old_avatar.id) != 1: + filename = os.path.join(current_app.config['AVATAR_UPLOAD_PATH'], os.path.basename(old_avatar.file_path)) + os.remove(filename) + old_avatar.delete() + return return_to + else: + flash('Something went wrong.;Please try again.', InfoboxKind.ERROR) + + return return_to + +@bp.post('//settings/clear-avatar') +@login_required +@user_required +@redirect_to_own +def clear_avatar(username): + user = get_active_user() + if user.is_default_avatar(): + return redirect(url_for('.settings', username=username)) + + old_avatar = Avatars.find({'id': user.avatar_id}) + user.update({'avatar_id': 1}) + filename = os.path.join(current_app.config['AVATAR_UPLOAD_PATH'], os.path.basename(old_avatar.file_path)) + os.remove(filename) + old_avatar.delete() + return redirect(url_for('.settings', username=username)) + +@bp.post('//settings/change-password') +@login_required +@redirect_to_own +def change_password(username): + user = get_active_user() + current_password = request.form.get('current_password', '') + new_password = request.form.get('new_password', '') + new_password2 = request.form.get('new_password2', '') + + new_hash = digest(new_password) + + old_correct = verify(user.password_hash, current_password) + new_match = compare_timesafe(new_password, new_password2) + + if (old_correct and new_match) == False: + flash('The current password is incorrect or the new passwords do not match.;Please try again.', InfoboxKind.ERROR) + return redirect(url_for('.settings', username=username)) + + user.update({ + 'password_hash': new_hash + }) + + revoke_all_sessions(user.id) + + return redirect(url_for('.log_in')) + +@bp.post('//settings/set-personalization') +@login_required +@user_required +@redirect_to_own +def set_personalization(username): + user = get_active_user() + session['sort_by'] = request.form.get('sort_by', 'activity') + session['dont_subscribe_by_default'] = not get_form_checkbox('subscribe_by_default') + + user.update({ + 'status': request.form.get('status', '')[:100], + 'display_name': parse_display_name(request.form.get('display_name', '')) + }) + + flash('Personalization settings updated.', InfoboxKind.INFO) + return redirect(url_for('.settings', username=username)) + +@bp.post('//settings/set-sig') +@login_required +@user_required +@redirect_to_own +def set_sig(username): + user = get_active_user() + user.set_signature(request.form.get('babycode_content', '')) + flash('Signature updated.', InfoboxKind.INFO) + return redirect(url_for('.settings', username=username)) + +@bp.post('//settings/set-bio') +@login_required +@user_required +@redirect_to_own +def set_bio(username): return 'stub' @bp.get('//inbox/') @@ -200,4 +378,3 @@ def inbox(username): def bookmarks(username): username = username.lower() return 'stub' - diff --git a/app/templates/common/macros.html b/app/templates/common/macros.html index 1fb1b0e..d19635a 100644 --- a/app/templates/common/macros.html +++ b/app/templates/common/macros.html @@ -72,7 +72,7 @@
{%- for tab_label in labels -%} - + {%- endfor -%}
{%- for tab_label in labels -%} @@ -87,25 +87,38 @@ placeholder='Post content', prefill='', required=true, - id='babycode-content' + id='babycode-content', + banned_tags=[] ) -%} {%- call(idx) tabs(prefix='babycode', labels=['Write', 'Preview']) -%} {%- if idx == 0 -%} - - - - - - - - - - - + + + + + + + + + + + + + {# stub: char count #} - - -babycode help + + + {%- if banned_tags -%} +
+ Forbidden tags: +
    + {%- for tag in banned_tags -%} +
  • {{tag}}
  • + {%- endfor -%} +
+
+ {%- endif -%} + babycode help {%- endif -%} {%- endcall -%} {%- endmacro %} @@ -132,7 +145,7 @@
{{avatar(post.avatar_path)}}
- {{post.display_name if post.display_name else post.username}} + {{post.display_name if post.display_name else post.username}} @{{post.username}} {{post.status}} {%- set badges=post.badges_json | fromjson -%} diff --git a/app/templates/threads/thread.html b/app/templates/threads/thread.html index abd9144..794d33b 100644 --- a/app/templates/threads/thread.html +++ b/app/templates/threads/thread.html @@ -87,7 +87,7 @@

Reply to "{{thread.title}}"

{{- babycode_editor_component() -}} - + diff --git a/app/templates/users/settings.html b/app/templates/users/settings.html new file mode 100644 index 0000000..e5174c9 --- /dev/null +++ b/app/templates/users/settings.html @@ -0,0 +1,80 @@ +{%- from 'common/macros.html' import babycode_editor_component -%} +{%- from 'common/macros.html' import subheader, avatar -%} +{%- extends 'base.html' -%} +{%- block title -%}settings{%- endblock -%} +{%- block content -%} +{%- set sub -%} +{%- if user.is_guest() -%}You are a guest. Your customization options are limited until a moderator confirms your account.{%- endif -%} +{%- endset -%} +{{- subheader('User settings', sub) -}} +{%- if not user.is_guest() -%} +
+ Avatar +
+ {{- avatar(user.get_avatar_url()) -}} + + + 1MB max. Will be cropped to square. + + + + +
+
+{%- endif -%} +
+ Change password +

After you change your password, you will be logged out of all sessions and will need to log in again.

+
+ + + + + + + +
+
+{%- if not user.is_guest() -%} +
+ Personalization +
+ + + + + + + + + + + +
+
+
+ Signature +
+

The signature will appear under each of your posts.

+ {{babycode_editor_component(id='signature-content', placeholder='Signature content', prefill=user.signature_original_markup, required=false, banned_tags=['@mention'])}} + +
+
+{#
+ About me/Bio +
+ Your bio will appear on your profile. + {{babycode_editor_component(id='bio-content', placeholder='Bio content', prefill=user.signature_original_markup, required=false, banned_tags=['@mention'])}} + +
+
#} +
+ Badges +
Loading badges…
+
If badges fail to load, make sure JS is enabled.
+
+{%- endif -%} +{%- endblock -%} diff --git a/app/templates/users/sign_up.html b/app/templates/users/sign_up.html index bb92381..ed2a80f 100644 --- a/app/templates/users/sign_up.html +++ b/app/templates/users/sign_up.html @@ -14,9 +14,9 @@ Please read the rules etc. stub - + - + diff --git a/data/static/css/style.css b/data/static/css/style.css index 1bb9eb8..c591cad 100644 --- a/data/static/css/style.css +++ b/data/static/css/style.css @@ -85,7 +85,7 @@ body { margin: var(--big-padding) var(--wrapper-side-margin); } -button, .linkbutton, input[type="submit"] { +button, .linkbutton, input[type="submit"], input[type="file"]::file-selector-button { --main-color: var(--button-color-primary); --font-color: var(--font-color-main); --border-color: hsl(from var(--main-color) h calc(s * 1.3) 25); @@ -189,7 +189,6 @@ button, .linkbutton, input[type="submit"] { } } - .babycode-editor { width: 100%; min-height: 150px; @@ -466,19 +465,6 @@ footer { gap: var(--base-padding); } -.settings-grid { - display: grid; - gap: var(--base-padding); - --grid-item-base-width: 600px; - --grid-item-max-width: calc((100% - var(--grid-item-base-width)) / 2); - grid-template-columns: repeat(auto-fill, minmax(max(var(--grid-item-base-width), var(--grid-item-max-width)), 1fr)); - - &> * { - height: fit-content; - width: 100%; - } -} - .thread-actions { display: flex; flex-wrap: wrap; @@ -524,6 +510,10 @@ footer { padding: var(--base-padding); } +.usercard-username { + word-wrap: anywhere; +} + .avatar-container { position: relative; display: flex; @@ -676,6 +666,18 @@ details.separated { margin: 0.5em 0; } +.avatar-form { + display: flex; + gap: var(--huge-padding); +} + +.avatar-form-controls { + display: flex; + flex-direction: column; + align-items: start; + justify-content: center; +} + /* babycode tags */ .inline-code { background-color: var(--code-bg-color); @@ -863,10 +865,6 @@ a.mention { margin-right: 0; } - .settings-grid { - --grid-item-base-width: 400px; - } - .mobile-fill-flex { width: 100%; } @@ -908,4 +906,22 @@ a.mention { max-width: min(75vw, 400px); max-height: 50vh; } + + .avatar-form { + flex-direction: column; + .avatar { + align-self: center; + } + } + .avatar-form-controls { + flex-direction: row; + gap: var(--base-padding); + flex-wrap: wrap; + } + + .avatar-form-size-label { + order: 999; + width: 100%; + text-align: center; + } }