From 1d5d5a8c64a9d4c211daa99542a37de67553e320 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Lera=20Elvo=C3=A9?=
"
+ case "mention":
+ return make_mention(element, mentions)
for i in range(len(elements)):
e = elements[i]
@@ -274,4 +312,4 @@ def babycode_to_html(s, banned_tags=None):
elements[i + 1] if i+1 < len(elements) else None
)
out = out + fold(e, False, surrounding)
- return out
+ return BabycodeParseResult(out, mentions)
diff --git a/app/lib/babycode_parser.py b/app/lib/babycode_parser.py
index f05dd7e..a858f05 100644
--- a/app/lib/babycode_parser.py
+++ b/app/lib/babycode_parser.py
@@ -6,12 +6,14 @@ PAT_EMOTE = r"[^\s:]"
PAT_BBCODE_TAG = r"\w"
PAT_BBCODE_ATTR = r"[^\]]"
PAT_LINK = r"https?:\/\/[\w\-_.?:\/=&~@#%]+[\w\-\/]"
+PAT_MENTION = r'[a-zA-Z0-9_-]'
class Parser:
def __init__(self, src_str):
- self.valid_bbcode_tags = []
+ self.valid_bbcode_tags = {}
self.valid_emotes = []
- self.bbcode_tags_only_text_children = [],
+ self.bbcode_tags_only_text_children = []
+ self.mentions_allowed = True
self.source = src_str
self.position = 0
self.position_stack = []
@@ -206,6 +208,25 @@ class Parser:
"url": word
}
+ def parse_mention(self):
+ if not self.mentions_allowed:
+ return None
+
+ self.save_position()
+
+ if not self.check_char('@'):
+ self.restore_position()
+ return None
+
+ mention = self.match_pattern(PAT_MENTION)
+ self.forget_position()
+ return {
+ "type": "mention",
+ "name": mention,
+ "start": self.position - len(mention) - 1,
+ "end": self.position,
+ }
+
def parse_element(self, siblings):
if self.is_end_of_source():
@@ -214,7 +235,8 @@ class Parser:
element = self.parse_emote() \
or self.parse_bbcode() \
or self.parse_rule() \
- or self.parse_link()
+ or self.parse_link() \
+ or self.parse_mention()
if element is None:
if len(siblings) > 0:
diff --git a/app/migrations.py b/app/migrations.py
index 3df9fb3..6def7de 100644
--- a/app/migrations.py
+++ b/app/migrations.py
@@ -22,6 +22,18 @@ def create_default_bookmark_collections():
for user in user_ids_without_default_collection:
BookmarkCollections.create_default(user['id'])
+def add_display_name():
+ dq = 'ALTER TABLE "users" ADD COLUMN "display_name" TEXT NOT NULL DEFAULT ""'
+ db.execute(dq)
+ from .models import Users
+ for user in Users.select():
+ data = {
+ 'username': user.username.lower(),
+ }
+ if user.username.lower() != user.username:
+ data['display_name'] = user.username
+ user.update(data)
+
# format: [str|tuple(str, any...)|callable]
MIGRATIONS = [
migrate_old_avatars,
@@ -30,6 +42,7 @@ MIGRATIONS = [
'ALTER TABLE "post_history" ADD COLUMN "format_version" INTEGER DEFAULT NULL',
add_signature_format,
create_default_bookmark_collections,
+ add_display_name,
]
def run_migrations():
diff --git a/app/models.py b/app/models.py
index 0b90639..7554c69 100644
--- a/app/models.py
+++ b/app/models.py
@@ -53,7 +53,8 @@ class Users(Model):
COUNT(DISTINCT threads.id) AS thread_count,
MAX(threads.title) FILTER (WHERE threads.created_at = latest.created_at) AS latest_thread_title,
MAX(threads.slug) FILTER (WHERE threads.created_at = latest.created_at) AS latest_thread_slug,
- inviter.username AS inviter_username
+ inviter.username AS inviter_username,
+ inviter.display_name AS inviter_display_name
FROM users
LEFT JOIN posts ON posts.user_id = users.id
LEFT JOIN threads ON threads.user_id = users.id
@@ -106,6 +107,15 @@ class Users(Model):
res = db.query(q, self.id)
return [BookmarkCollections.find({'id': bc['id']}) for bc in res]
+ def get_readable_name(self):
+ if self.display_name:
+ return self.display_name
+
+ return self.username
+
+ def has_display_name(self):
+ return self.display_name != ''
+
class Topics(Model):
table = "topics"
@@ -116,6 +126,7 @@ class Topics(Model):
SELECT
topics.id, topics.name, topics.slug, topics.description, topics.is_locked,
users.username AS latest_thread_username,
+ users.display_name AS latest_thread_display_name,
threads.title AS latest_thread_title,
threads.slug AS latest_thread_slug,
threads.created_at AS latest_thread_created_at
@@ -141,7 +152,7 @@ class Topics(Model):
SELECT
threads.topic_id, threads.id AS thread_id, threads.title AS thread_title, threads.slug AS thread_slug,
posts.id AS post_id, posts.created_at AS post_created_at,
- users.username,
+ users.username, users.display_name,
ROW_NUMBER() OVER (PARTITION BY threads.topic_id ORDER BY posts.created_at DESC) AS rn
FROM
threads
@@ -154,7 +165,7 @@ class Topics(Model):
topic_id,
thread_id, thread_title, thread_slug,
post_id, post_created_at,
- username
+ username, display_name
FROM
ranked_threads
WHERE
@@ -170,6 +181,7 @@ class Topics(Model):
'thread_slug': thread['thread_slug'],
'post_id': thread['post_id'],
'username': thread['username'],
+ 'display_name': thread['display_name'],
'post_created_at': thread['post_created_at']
}
return active_threads
@@ -185,7 +197,9 @@ class Topics(Model):
SELECT
threads.id, threads.title, threads.slug, threads.created_at, threads.is_locked, threads.is_stickied,
users.username AS started_by,
- u.username AS latest_post_username,
+ users.display_name AS started_by_display_name,
+ u.username AS latest_post_username,
+ u.display_name AS latest_post_display_name,
ph.content AS latest_post_content,
posts.created_at AS latest_post_created_at,
posts.id AS latest_post_id
@@ -230,7 +244,13 @@ class Threads(Model):
class Posts(Model):
FULL_POSTS_QUERY = """
SELECT
- posts.id, posts.created_at, post_history.content, post_history.edited_at, users.username, users.status, avatars.file_path AS avatar_path, posts.thread_id, users.id AS user_id, post_history.original_markup, users.signature_rendered, threads.slug AS thread_slug, threads.is_locked AS thread_is_locked, threads.title AS thread_title
+ posts.id, posts.created_at,
+ post_history.content, post_history.edited_at,
+ users.username, users.display_name, users.status,
+ avatars.file_path AS avatar_path, posts.thread_id,
+ users.id AS user_id, post_history.original_markup,
+ users.signature_rendered, threads.slug AS thread_slug,
+ threads.is_locked AS thread_is_locked, threads.title AS thread_title
FROM
posts
JOIN
@@ -410,3 +430,7 @@ class MOTD(Model):
q = 'SELECT id FROM motd'
res = db.query(q)
return [MOTD.find({'id': i['id']}) for i in res]
+
+
+class Mentions(Model):
+ table = 'mentions'
diff --git a/app/routes/api.py b/app/routes/api.py
index 3442906..9e0375b 100644
--- a/app/routes/api.py
+++ b/app/routes/api.py
@@ -41,7 +41,7 @@ def babycode_preview():
if not markup or not isinstance(markup, str):
return {'error': 'markup field missing or invalid type'}, 400
banned_tags = request.json.get('banned_tags', [])
- rendered = babycode_to_html(markup, banned_tags)
+ rendered = babycode_to_html(markup, banned_tags).result
return {'html': rendered}
@@ -213,3 +213,16 @@ def bookmark_thread(thread_id):
return {'error': 'bad request'}, 400
return {'status': 'ok'}, 200
+
+@bp.get('/current-user')
+def get_current_user_info():
+ if not is_logged_in():
+ return {'user': null}
+
+ user = get_active_user()
+ return {
+ 'user': {
+ 'username': user.username,
+ 'display_name': user.display_name,
+ }
+ }
diff --git a/app/routes/mod.py b/app/routes/mod.py
index 8b3534d..0c5199f 100644
--- a/app/routes/mod.py
+++ b/app/routes/mod.py
@@ -73,7 +73,7 @@ def motd_editor_form():
data = {
'title': title,
'body_original_markup': orig_body,
- 'body_rendered': babycode_to_html(orig_body, banned_tags=MOTD_BANNED_TAGS),
+ 'body_rendered': babycode_to_html(orig_body, banned_tags=MOTD_BANNED_TAGS).result,
'format_version': BABYCODE_VERSION,
'edited_at': int(time.time()),
}
diff --git a/app/routes/posts.py b/app/routes/posts.py
index 6138d54..8a8c38d 100644
--- a/app/routes/posts.py
+++ b/app/routes/posts.py
@@ -5,7 +5,7 @@ from .users import login_required, get_active_user
from ..lib.babycode import babycode_to_html, BABYCODE_VERSION
from ..constants import InfoboxKind
from ..db import db
-from ..models import Posts, PostHistory, Threads, Topics
+from ..models import Posts, PostHistory, Threads, Topics, Mentions
bp = Blueprint("posts", __name__, url_prefix = "/post")
@@ -21,13 +21,22 @@ def create_post(thread_id, user_id, content, markup_language="babycode"):
revision = PostHistory.create({
"post_id": post.id,
- "content": parsed_content,
+ "content": parsed_content.result,
"is_initial_revision": True,
"original_markup": content,
"markup_language": markup_language,
"format_version": BABYCODE_VERSION,
})
+ for mention in parsed_content.mentions:
+ Mentions.create({
+ 'revision_id': revision.id,
+ 'mentioned_user_id': mention['mentioned_user_id'],
+ 'original_mention_text': mention['mention_text'],
+ 'start_index': mention['start'],
+ 'end_index': mention['end'],
+ })
+
post.update({"current_revision_id": revision.id})
return post
@@ -38,13 +47,22 @@ def update_post(post_id, new_content, markup_language='babycode'):
post = Posts.find({'id': post_id})
new_revision = PostHistory.create({
'post_id': post.id,
- 'content': parsed_content,
+ 'content': parsed_content.result,
'is_initial_revision': False,
'original_markup': new_content,
'markup_language': markup_language,
'format_version': BABYCODE_VERSION,
})
+ for mention in parsed_content.mentions:
+ Mentions.create({
+ 'revision_id': new_revision.id,
+ 'mentioned_user_id': mention['mentioned_user_id'],
+ 'original_mention_text': mention['mention_text'],
+ 'start_index': mention['start'],
+ 'end_index': mention['end'],
+ })
+
post.update({'current_revision_id': new_revision.id})
diff --git a/app/routes/threads.py b/app/routes/threads.py
index 63c51e9..586ab25 100644
--- a/app/routes/threads.py
+++ b/app/routes/threads.py
@@ -48,7 +48,6 @@ def thread(slug):
page = math.ceil((post_position) / POSTS_PER_PAGE)
else:
page = max(1, min(page_count, int(request.args.get("page", default = 1))))
-
posts = thread.get_posts(POSTS_PER_PAGE, (page - 1) * POSTS_PER_PAGE)
topic = Topics.find({"id": thread.topic_id})
other_topics = Topics.select()
diff --git a/app/routes/users.py b/app/routes/users.py
index cb802ee..f8fe4d0 100644
--- a/app/routes/users.py
+++ b/app/routes/users.py
@@ -4,7 +4,7 @@ from flask import (
from functools import wraps
from ..db import db
from ..lib.babycode import babycode_to_html, BABYCODE_VERSION
-from ..models import Users, Sessions, Subscriptions, Avatars, PasswordResetLinks, InviteKeys, BookmarkCollections, BookmarkedThreads
+from ..models import Users, Sessions, Subscriptions, Avatars, PasswordResetLinks, InviteKeys, BookmarkCollections, BookmarkedThreads, Mentions, PostHistory
from ..constants import InfoboxKind, PermissionLevel
from ..auth import digest, verify
from wand.image import Image
@@ -90,6 +90,15 @@ def validate_username(username):
return bool(re.fullmatch(pattern, username))
+def validate_display_name(display_name):
+ if not display_name:
+ return True
+
+ pattern = r'^[\w!#$%^*\(\)\-_=+\[\]\{\}\|;:,.?\s]{3,50}$'
+ display_name = display_name.replace('@', '_')
+ return bool(re.fullmatch(pattern, display_name))
+
+
def redirect_if_logged_in(*args, **kwargs):
def decorator(view_func):
@wraps(view_func)
@@ -184,7 +193,7 @@ def log_in():
@redirect_if_logged_in(".page", username = lambda: get_active_user().username)
def log_in_post():
target_user = Users.find({
- "username": request.form['username']
+ "username": request.form['username'].lower()
})
if not target_user:
flash("Incorrect username or password.", InfoboxKind.ERROR)
@@ -239,7 +248,7 @@ def sign_up_post():
flash("Invalid username.", InfoboxKind.ERROR)
return redirect(url_for("users.sign_up", key=key))
- user_exists = Users.count({"username": username}) > 0
+ user_exists = Users.count({"username": username.lower()}) > 0
if user_exists:
flash(f"Username '{username}' is already taken.", InfoboxKind.ERROR)
return redirect(url_for("users.sign_up", key=key))
@@ -254,8 +263,14 @@ def sign_up_post():
hashed = digest(password)
+ if username.lower() != username:
+ display_name = username
+ else:
+ display_name = ''
+
new_user = Users.create({
"username": username,
+ 'display_name': display_name,
"password_hash": hashed,
"permission": PermissionLevel.GUEST.value,
})
@@ -279,14 +294,14 @@ def sign_up_post():
@bp.get("/[img=https://forum.poto.cafe/avatars/default.webp]the Python logo with a cowboy hat[/img]
{{ '[img=/static/avatars/default.webp]the Python logo with a cowboy hat[/img]' | babycode | safe }}
Text inside the tag becomes the alt text. The attribute is the image URL. The text inside the tag will become the image's alt text.
+The attribute is the image URL. The text inside the tag will become the image's alt text.
Images will always break up a paragraph and will get scaled down to a maximum of 400px. However, consecutive image tags will try to stay in one line, wrapping if necessary. Break the paragraph if you wish to keep images on their own paragraph.
Multiple images attached to a post can be clicked to open a dialog to view them.
@@ -157,13 +157,22 @@ {{ list | babycode | safe }}You can make a section collapsible by using the [spoiler] tag:
You can make a section collapsible by using the [spoiler] tag:
You can mention users by their username (not their display name) by using @username. A user's username is always shown below their avatar and display name on their posts and their user page.
A mention will show up on your post as a clickable box with the user's display name if they have one set or their username with an @ symbol if they don't:
Mentioning a user does not notify them. It is simply a way to link to their profile in your posts.
[{{ tag }}]{{ tag }}You have been invited by {{ inviter.username }} to join {{ config.SITE_NAME }}. Create an identity below.
+You have been invited by {{ inviter.get_readable_name() }} to join {{ config.SITE_NAME }}. Create an identity below.
{% endif %}