Compare commits

...

11 Commits

35 changed files with 581 additions and 135 deletions

View File

@@ -1,4 +1,4 @@
from flask import Flask, session, request from flask import Flask, session, request, render_template
from dotenv import load_dotenv from dotenv import load_dotenv
from .models import Avatars, Users, PostHistory, Posts, MOTD from .models import Avatars, Users, PostHistory, Posts, MOTD
from .auth import digest from .auth import digest
@@ -7,7 +7,7 @@ from .routes.threads import get_post_url
from .constants import ( from .constants import (
PermissionLevel, permission_level_string, PermissionLevel, permission_level_string,
InfoboxKind, InfoboxHTMLClass, InfoboxKind, InfoboxHTMLClass,
REACTION_EMOJI, REACTION_EMOJI, MOTD_BANNED_TAGS,
) )
from .lib.babycode import babycode_to_html, EMOJI, BABYCODE_VERSION from .lib.babycode import babycode_to_html, EMOJI, BABYCODE_VERSION
from datetime import datetime from datetime import datetime
@@ -40,10 +40,11 @@ def create_admin():
def create_deleted_user(): def create_deleted_user():
username = "DeletedUser" username = "DeletedUser"
if Users.count({"username": username}) == 0: if Users.count({"username": username.lower()}) == 0:
print("Creating DeletedUser") print("Creating DeletedUser")
Users.create({ Users.create({
"username": username, "username": username.lower(),
"display_name": username,
"password_hash": "", "password_hash": "",
"permission": PermissionLevel.SYSTEM.value, "permission": PermissionLevel.SYSTEM.value,
}) })
@@ -61,7 +62,7 @@ def reparse_babycode():
with db.transaction(): with db.transaction():
for ph in post_histories: for ph in post_histories:
ph.update({ ph.update({
'content': babycode_to_html(ph['original_markup']), 'content': babycode_to_html(ph['original_markup']).result,
'format_version': BABYCODE_VERSION, 'format_version': BABYCODE_VERSION,
}) })
print('Re-parsing posts done.') print('Re-parsing posts done.')
@@ -76,7 +77,7 @@ def reparse_babycode():
with db.transaction(): with db.transaction():
for user in users_with_sigs: for user in users_with_sigs:
user.update({ user.update({
'signature_rendered': babycode_to_html(user['signature_original_markup']), 'signature_rendered': babycode_to_html(user['signature_original_markup']).result,
'signature_format_version': BABYCODE_VERSION, 'signature_format_version': BABYCODE_VERSION,
}) })
print(f'Re-parsed {len(users_with_sigs)} user sigs.') print(f'Re-parsed {len(users_with_sigs)} user sigs.')
@@ -90,7 +91,7 @@ def reparse_babycode():
with db.transaction(): with db.transaction():
for motd in stale_motds: for motd in stale_motds:
motd.update({ motd.update({
'body_rendered': babycode_to_html(motd['body_original_markup'], banned_tags=MOTD_BANNED_TAGS), 'body_rendered': babycode_to_html(motd['body_original_markup'], banned_tags=MOTD_BANNED_TAGS).result,
'format_version': BABYCODE_VERSION, 'format_version': BABYCODE_VERSION,
}) })
print('Re-parsing MOTDs done.') print('Re-parsing MOTDs done.')
@@ -175,6 +176,7 @@ def create_app():
"__commit": commit, "__commit": commit,
"__emoji": EMOJI, "__emoji": EMOJI,
"REACTION_EMOJI": REACTION_EMOJI, "REACTION_EMOJI": REACTION_EMOJI,
"MOTD_BANNED_TAGS": MOTD_BANNED_TAGS,
} }
@app.context_processor @app.context_processor
@@ -206,7 +208,7 @@ def create_app():
@app.template_filter('babycode') @app.template_filter('babycode')
def babycode_filter(markup): def babycode_filter(markup):
return babycode_to_html(markup) return babycode_to_html(markup).result
@app.template_filter('extract_h2') @app.template_filter('extract_h2')
def extract_h2(content): def extract_h2(content):
@@ -225,7 +227,7 @@ def create_app():
elif request.path.startswith('/api/'): elif request.path.startswith('/api/'):
return {'error': 'not found'}, e.code return {'error': 'not found'}, e.code
else: else:
return e return render_template('common/404.html'), e.code
# this only happens at build time but # this only happens at build time but
# build time is when updates are done anyway # build time is when updates are done anyway

View File

@@ -48,7 +48,7 @@ REACTION_EMOJI = [
] ]
MOTD_BANNED_TAGS = [ MOTD_BANNED_TAGS = [
'img', 'spoiler', 'img', 'spoiler', '@mention'
] ]
def permission_level_string(perm): def permission_level_string(perm):

View File

@@ -189,6 +189,13 @@ class Model:
raise AttributeError(f"No column '{key}'") raise AttributeError(f"No column '{key}'")
@classmethod
def from_data(cls, data):
instance = cls(cls.table)
instance._data = dict(data)
return instance
@classmethod @classmethod
def find(cls, condition): def find(cls, condition):
row = db.QueryBuilder(cls.table)\ row = db.QueryBuilder(cls.table)\
@@ -196,9 +203,7 @@ class Model:
.first() .first()
if not row: if not row:
return None return None
instance = cls(cls.table) return cls.from_data(row)
instance._data = dict(row)
return instance
@classmethod @classmethod
@@ -207,11 +212,7 @@ class Model:
.where(condition, operator)\ .where(condition, operator)\
.all() .all()
res = [] res = []
for row in rows: return [cls.from_data(row) for row in rows]
instance = cls(cls.table)
instance._data = dict(row)
res.append(instance)
return res
@classmethod @classmethod
@@ -223,9 +224,7 @@ class Model:
row = db.insert(cls.table, columns, *values.values()) row = db.insert(cls.table, columns, *values.values())
if row: if row:
instance = cls(cls.table) return cls.from_data(row)
instance._data = row
return instance
return None return None
@@ -243,7 +242,8 @@ class Model:
def select(cls, sel = "*"): def select(cls, sel = "*"):
qb = db.QueryBuilder(cls.table).select(sel) qb = db.QueryBuilder(cls.table).select(sel)
result = qb.all() result = qb.all()
return result if result else [] # return result if result else []
return [cls.from_data(data) for data in (result if result else [])]
def update(self, data): def update(self, data):

View File

@@ -6,6 +6,16 @@ from pygments.lexers import get_lexer_by_name
from pygments.util import ClassNotFound as PygmentsClassNotFound from pygments.util import ClassNotFound as PygmentsClassNotFound
import re import re
class BabycodeParseResult:
def __init__(self, result, mentions=[]):
self.result = result
self.mentions = mentions
def __str__(self):
return self.result
BABYCODE_VERSION = 5 BABYCODE_VERSION = 5
NAMED_COLORS = [ NAMED_COLORS = [
@@ -78,6 +88,9 @@ def tag_list(children):
return " ".join([f"<li>{x}</li>" for x in list_body.split("\1") if x]) return " ".join([f"<li>{x}</li>" for x in list_body.split("\1") if x])
def tag_color(children, attr, surrounding): def tag_color(children, attr, surrounding):
if not attr:
return f"[color]{children}[/color]"
hex_re = r"^#?([0-9a-f]{6}|[0-9a-f]{3})$" hex_re = r"^#?([0-9a-f]{6}|[0-9a-f]{3})$"
potential_color = attr.lower().strip() potential_color = attr.lower().strip()
@@ -206,6 +219,24 @@ def is_inline(e):
return e['type'] != 'rule' return e['type'] != 'rule'
def make_mention(e, mentions):
from ..models import Users
from flask import url_for
target_user = Users.find({'username': e['name'].lower()})
if not target_user:
return f"@{e['name']}"
mention_data = {
'mention_text': f"@{e['name']}",
'mentioned_user_id': int(target_user.id),
"start": e['start'],
"end": e['end'],
}
if mention_data not in mentions:
mentions.append(mention_data)
return f"<a class='mention{' display' if target_user.has_display_name() else ''}' href='{url_for('users.page', username=target_user.username)}' title='@{target_user.username}' data-init='highlightMentions' data-username='{target_user.username}'>{'@' if not target_user.has_display_name() else ''}{target_user.get_readable_name()}</a>"
def should_collapse(text, surrounding): def should_collapse(text, surrounding):
if not isinstance(text, str): if not isinstance(text, str):
return False return False
@@ -218,15 +249,19 @@ def should_collapse(text, surrounding):
return False return False
def babycode_to_html(s, banned_tags=None): def sanitize(s):
allowed_tags = list(TAGS.keys()) return escape(s.strip().replace('\r\n', '\n').replace('\r', '\n'))
def babycode_to_html(s, banned_tags={}):
allowed_tags = set(TAGS.keys())
if banned_tags is not None: if banned_tags is not None:
for tag in banned_tags: for tag in banned_tags:
allowed_tags.remove(tag) allowed_tags.discard(tag)
subj = escape(s.strip().replace('\r\n', '\n').replace('\r', '\n')) subj = sanitize(s)
parser = Parser(subj) parser = Parser(subj)
parser.valid_bbcode_tags = allowed_tags parser.valid_bbcode_tags = allowed_tags
parser.bbcode_tags_only_text_children = TEXT_ONLY parser.bbcode_tags_only_text_children = TEXT_ONLY
parser.mentions_allowed = '@mention' not in banned_tags
parser.valid_emotes = EMOJI.keys() parser.valid_emotes = EMOJI.keys()
uncollapsed = parser.parse() uncollapsed = parser.parse()
@@ -241,6 +276,7 @@ def babycode_to_html(s, banned_tags=None):
elements.append(e) elements.append(e)
out = "" out = ""
mentions = []
def fold(element, nobr, surrounding): def fold(element, nobr, surrounding):
if isinstance(element, str): if isinstance(element, str):
if nobr: if nobr:
@@ -266,6 +302,8 @@ def babycode_to_html(s, banned_tags=None):
return EMOJI[element['name']] return EMOJI[element['name']]
case "rule": case "rule":
return "<hr>" return "<hr>"
case "mention":
return make_mention(element, mentions)
for i in range(len(elements)): for i in range(len(elements)):
e = elements[i] 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 elements[i + 1] if i+1 < len(elements) else None
) )
out = out + fold(e, False, surrounding) out = out + fold(e, False, surrounding)
return out return BabycodeParseResult(out, mentions)

View File

@@ -6,12 +6,14 @@ PAT_EMOTE = r"[^\s:]"
PAT_BBCODE_TAG = r"\w" PAT_BBCODE_TAG = r"\w"
PAT_BBCODE_ATTR = r"[^\]]" PAT_BBCODE_ATTR = r"[^\]]"
PAT_LINK = r"https?:\/\/[\w\-_.?:\/=&~@#%]+[\w\-\/]" PAT_LINK = r"https?:\/\/[\w\-_.?:\/=&~@#%]+[\w\-\/]"
PAT_MENTION = r'[a-zA-Z0-9_-]'
class Parser: class Parser:
def __init__(self, src_str): def __init__(self, src_str):
self.valid_bbcode_tags = [] self.valid_bbcode_tags = {}
self.valid_emotes = [] self.valid_emotes = []
self.bbcode_tags_only_text_children = [], self.bbcode_tags_only_text_children = []
self.mentions_allowed = True
self.source = src_str self.source = src_str
self.position = 0 self.position = 0
self.position_stack = [] self.position_stack = []
@@ -206,6 +208,25 @@ class Parser:
"url": word "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): def parse_element(self, siblings):
if self.is_end_of_source(): if self.is_end_of_source():
@@ -214,7 +235,8 @@ class Parser:
element = self.parse_emote() \ element = self.parse_emote() \
or self.parse_bbcode() \ or self.parse_bbcode() \
or self.parse_rule() \ or self.parse_rule() \
or self.parse_link() or self.parse_link() \
or self.parse_mention()
if element is None: if element is None:
if len(siblings) > 0: if len(siblings) > 0:

View File

@@ -22,6 +22,18 @@ def create_default_bookmark_collections():
for user in user_ids_without_default_collection: for user in user_ids_without_default_collection:
BookmarkCollections.create_default(user['id']) 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] # format: [str|tuple(str, any...)|callable]
MIGRATIONS = [ MIGRATIONS = [
migrate_old_avatars, migrate_old_avatars,
@@ -30,6 +42,7 @@ MIGRATIONS = [
'ALTER TABLE "post_history" ADD COLUMN "format_version" INTEGER DEFAULT NULL', 'ALTER TABLE "post_history" ADD COLUMN "format_version" INTEGER DEFAULT NULL',
add_signature_format, add_signature_format,
create_default_bookmark_collections, create_default_bookmark_collections,
add_display_name,
] ]
def run_migrations(): def run_migrations():

View File

@@ -53,7 +53,8 @@ class Users(Model):
COUNT(DISTINCT threads.id) AS thread_count, COUNT(DISTINCT threads.id) AS thread_count,
MAX(threads.title) FILTER (WHERE threads.created_at = latest.created_at) AS latest_thread_title, 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, 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 FROM users
LEFT JOIN posts ON posts.user_id = users.id LEFT JOIN posts ON posts.user_id = users.id
LEFT JOIN threads ON threads.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) res = db.query(q, self.id)
return [BookmarkCollections.find({'id': bc['id']}) for bc in res] 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): class Topics(Model):
table = "topics" table = "topics"
@@ -116,6 +126,7 @@ class Topics(Model):
SELECT SELECT
topics.id, topics.name, topics.slug, topics.description, topics.is_locked, topics.id, topics.name, topics.slug, topics.description, topics.is_locked,
users.username AS latest_thread_username, users.username AS latest_thread_username,
users.display_name AS latest_thread_display_name,
threads.title AS latest_thread_title, threads.title AS latest_thread_title,
threads.slug AS latest_thread_slug, threads.slug AS latest_thread_slug,
threads.created_at AS latest_thread_created_at threads.created_at AS latest_thread_created_at
@@ -141,7 +152,7 @@ class Topics(Model):
SELECT SELECT
threads.topic_id, threads.id AS thread_id, threads.title AS thread_title, threads.slug AS thread_slug, 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, 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 ROW_NUMBER() OVER (PARTITION BY threads.topic_id ORDER BY posts.created_at DESC) AS rn
FROM FROM
threads threads
@@ -154,7 +165,7 @@ class Topics(Model):
topic_id, topic_id,
thread_id, thread_title, thread_slug, thread_id, thread_title, thread_slug,
post_id, post_created_at, post_id, post_created_at,
username username, display_name
FROM FROM
ranked_threads ranked_threads
WHERE WHERE
@@ -170,6 +181,7 @@ class Topics(Model):
'thread_slug': thread['thread_slug'], 'thread_slug': thread['thread_slug'],
'post_id': thread['post_id'], 'post_id': thread['post_id'],
'username': thread['username'], 'username': thread['username'],
'display_name': thread['display_name'],
'post_created_at': thread['post_created_at'] 'post_created_at': thread['post_created_at']
} }
return active_threads return active_threads
@@ -185,7 +197,9 @@ class Topics(Model):
SELECT SELECT
threads.id, threads.title, threads.slug, threads.created_at, threads.is_locked, threads.is_stickied, threads.id, threads.title, threads.slug, threads.created_at, threads.is_locked, threads.is_stickied,
users.username AS started_by, 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, ph.content AS latest_post_content,
posts.created_at AS latest_post_created_at, posts.created_at AS latest_post_created_at,
posts.id AS latest_post_id posts.id AS latest_post_id
@@ -230,7 +244,13 @@ class Threads(Model):
class Posts(Model): class Posts(Model):
FULL_POSTS_QUERY = """ FULL_POSTS_QUERY = """
SELECT 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 FROM
posts posts
JOIN JOIN
@@ -410,3 +430,7 @@ class MOTD(Model):
q = 'SELECT id FROM motd' q = 'SELECT id FROM motd'
res = db.query(q) res = db.query(q)
return [MOTD.find({'id': i['id']}) for i in res] return [MOTD.find({'id': i['id']}) for i in res]
class Mentions(Model):
table = 'mentions'

View File

@@ -41,7 +41,7 @@ def babycode_preview():
if not markup or not isinstance(markup, str): if not markup or not isinstance(markup, str):
return {'error': 'markup field missing or invalid type'}, 400 return {'error': 'markup field missing or invalid type'}, 400
banned_tags = request.json.get('banned_tags', []) 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} return {'html': rendered}
@@ -213,3 +213,16 @@ def bookmark_thread(thread_id):
return {'error': 'bad request'}, 400 return {'error': 'bad request'}, 400
return {'status': 'ok'}, 200 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,
}
}

View File

@@ -73,7 +73,7 @@ def motd_editor_form():
data = { data = {
'title': title, 'title': title,
'body_original_markup': orig_body, '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, 'format_version': BABYCODE_VERSION,
'edited_at': int(time.time()), 'edited_at': int(time.time()),
} }

View File

@@ -5,7 +5,7 @@ from .users import login_required, get_active_user
from ..lib.babycode import babycode_to_html, BABYCODE_VERSION from ..lib.babycode import babycode_to_html, BABYCODE_VERSION
from ..constants import InfoboxKind from ..constants import InfoboxKind
from ..db import db 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") 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({ revision = PostHistory.create({
"post_id": post.id, "post_id": post.id,
"content": parsed_content, "content": parsed_content.result,
"is_initial_revision": True, "is_initial_revision": True,
"original_markup": content, "original_markup": content,
"markup_language": markup_language, "markup_language": markup_language,
"format_version": BABYCODE_VERSION, "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}) post.update({"current_revision_id": revision.id})
return post return post
@@ -38,13 +47,22 @@ def update_post(post_id, new_content, markup_language='babycode'):
post = Posts.find({'id': post_id}) post = Posts.find({'id': post_id})
new_revision = PostHistory.create({ new_revision = PostHistory.create({
'post_id': post.id, 'post_id': post.id,
'content': parsed_content, 'content': parsed_content.result,
'is_initial_revision': False, 'is_initial_revision': False,
'original_markup': new_content, 'original_markup': new_content,
'markup_language': markup_language, 'markup_language': markup_language,
'format_version': BABYCODE_VERSION, '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}) post.update({'current_revision_id': new_revision.id})
@@ -53,7 +71,8 @@ def update_post(post_id, new_content, markup_language='babycode'):
def delete(post_id): def delete(post_id):
post = Posts.find({'id': post_id}) post = Posts.find({'id': post_id})
if not post: if not post:
return redirect(url_for('topics.all_topics')) abort(404)
return
thread = Threads.find({'id': post.thread_id}) thread = Threads.find({'id': post.thread_id})
user = get_active_user() user = get_active_user()
@@ -85,13 +104,15 @@ def delete(post_id):
def edit(post_id): def edit(post_id):
post = Posts.find({'id': post_id}) post = Posts.find({'id': post_id})
if not post: if not post:
return redirect(url_for('topics.all_topics')) abort(404)
return
user = get_active_user() user = get_active_user()
q = f"{Posts.FULL_POSTS_QUERY} WHERE posts.id = ?" q = f"{Posts.FULL_POSTS_QUERY} WHERE posts.id = ?"
editing_post = db.fetch_one(q, post_id) editing_post = db.fetch_one(q, post_id)
if not editing_post: if not editing_post:
return redirect(url_for('topics.all_topics')) abort(404)
return
if editing_post['user_id'] != user.id: if editing_post['user_id'] != user.id:
return redirect(url_for('topics.all_topics')) return redirect(url_for('topics.all_topics'))
@@ -118,7 +139,8 @@ def edit_form(post_id):
user = get_active_user() user = get_active_user()
post = Posts.find({'id': post_id}) post = Posts.find({'id': post_id})
if not post: if not post:
return redirect(url_for('topics.all_topics')) abort(404)
return
if post.user_id != user.id: if post.user_id != user.id:
return redirect(url_for('topics.all_topics')) return redirect(url_for('topics.all_topics'))

View File

@@ -1,5 +1,6 @@
from flask import ( from flask import (
Blueprint, render_template, request, redirect, url_for, flash Blueprint, render_template, request, redirect, url_for, flash,
abort,
) )
from .users import login_required, mod_only, get_active_user, is_logged_in from .users import login_required, mod_only, get_active_user, is_logged_in
from ..db import db from ..db import db
@@ -32,7 +33,8 @@ def thread(slug):
POSTS_PER_PAGE = 10 POSTS_PER_PAGE = 10
thread = Threads.find({"slug": slug}) thread = Threads.find({"slug": slug})
if not thread: if not thread:
return redirect(url_for('topics.all_topics')) abort(404)
return
post_count = Posts.count({"thread_id": thread.id}) post_count = Posts.count({"thread_id": thread.id})
page_count = max(math.ceil(post_count / POSTS_PER_PAGE), 1) page_count = max(math.ceil(post_count / POSTS_PER_PAGE), 1)
@@ -48,7 +50,6 @@ def thread(slug):
page = math.ceil((post_position) / POSTS_PER_PAGE) page = math.ceil((post_position) / POSTS_PER_PAGE)
else: else:
page = max(1, min(page_count, int(request.args.get("page", default = 1)))) 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) posts = thread.get_posts(POSTS_PER_PAGE, (page - 1) * POSTS_PER_PAGE)
topic = Topics.find({"id": thread.topic_id}) topic = Topics.find({"id": thread.topic_id})
other_topics = Topics.select() other_topics = Topics.select()
@@ -87,7 +88,8 @@ def thread(slug):
def reply(slug): def reply(slug):
thread = Threads.find({"slug": slug}) thread = Threads.find({"slug": slug})
if not thread: if not thread:
return redirect(url_for('topics.all_topics')) abort(404)
return
user = get_active_user() user = get_active_user()
if user.is_guest(): if user.is_guest():
return redirect(url_for('.thread', slug=slug)) return redirect(url_for('.thread', slug=slug))
@@ -149,7 +151,8 @@ def lock(slug):
user = get_active_user() user = get_active_user()
thread = Threads.find({'slug': slug}) thread = Threads.find({'slug': slug})
if not thread: if not thread:
return redirect(url_for('topics.all_topics')) abort(404)
return
if not ((thread.user_id == user.id) or user.is_mod()): if not ((thread.user_id == user.id) or user.is_mod()):
return redirect(url_for('.thread', slug=slug)) return redirect(url_for('.thread', slug=slug))
target_op = request.form.get('target_op') target_op = request.form.get('target_op')
@@ -166,7 +169,8 @@ def sticky(slug):
user = get_active_user() user = get_active_user()
thread = Threads.find({'slug': slug}) thread = Threads.find({'slug': slug})
if not thread: if not thread:
return redirect(url_for('topics.all_topics')) abort(404)
return
if not ((thread.user_id == user.id) or user.is_mod()): if not ((thread.user_id == user.id) or user.is_mod()):
return redirect(url_for('.thread', slug=slug)) return redirect(url_for('.thread', slug=slug))
target_op = request.form.get('target_op') target_op = request.form.get('target_op')

View File

@@ -1,5 +1,6 @@
from flask import ( from flask import (
Blueprint, render_template, request, redirect, url_for, flash, session Blueprint, render_template, request, redirect, url_for, flash, session,
abort,
) )
from .users import login_required, mod_only, get_active_user, is_logged_in from .users import login_required, mod_only, get_active_user, is_logged_in
from ..models import Users, Topics, Threads, Subscriptions from ..models import Users, Topics, Threads, Subscriptions
@@ -50,7 +51,8 @@ def topic(slug):
"slug": slug "slug": slug
}) })
if not target_topic: if not target_topic:
return redirect(url_for('.all_topics')) abort(404)
return
threads_count = Threads.count({ threads_count = Threads.count({
"topic_id": target_topic.id "topic_id": target_topic.id
@@ -88,7 +90,8 @@ def topic(slug):
def edit(slug): def edit(slug):
topic = Topics.find({"slug": slug}) topic = Topics.find({"slug": slug})
if not topic: if not topic:
return redirect(url_for('.all_topics')) abort(404)
return
return render_template("topics/edit.html", topic=topic) return render_template("topics/edit.html", topic=topic)
@@ -98,7 +101,8 @@ def edit(slug):
def edit_post(slug): def edit_post(slug):
topic = Topics.find({"slug": slug}) topic = Topics.find({"slug": slug})
if not topic: if not topic:
return redirect(url_for('.all_topics')) abort(404)
return
topic.update({ topic.update({
"name": request.form.get('name', default = topic.name).strip(), "name": request.form.get('name', default = topic.name).strip(),
@@ -115,7 +119,8 @@ def edit_post(slug):
def delete(slug): def delete(slug):
topic = Topics.find({"slug": slug}) topic = Topics.find({"slug": slug})
if not topic: if not topic:
return redirect(url_for('.all_topics')) abort(404)
return
topic.delete() topic.delete()

View File

@@ -1,10 +1,15 @@
from flask import ( from flask import (
Blueprint, render_template, request, redirect, url_for, flash, session, current_app Blueprint, render_template, request, redirect, url_for, flash, session, current_app, abort
) )
from functools import wraps from functools import wraps
from ..db import db from ..db import db
from ..lib.babycode import babycode_to_html, BABYCODE_VERSION 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 ..constants import InfoboxKind, PermissionLevel
from ..auth import digest, verify from ..auth import digest, verify
from wand.image import Image from wand.image import Image
@@ -68,6 +73,7 @@ def create_session(user_id):
"expires_at": int(time.time()) + 31 * 24 * 60 * 60, "expires_at": int(time.time()) + 31 * 24 * 60 * 60,
}) })
def extend_session(user_id): def extend_session(user_id):
session_obj = Sessions.find({'key': session['pyrom_session_key']}) session_obj = Sessions.find({'key': session['pyrom_session_key']})
if not session_obj: if not session_obj:
@@ -90,6 +96,15 @@ def validate_username(username):
return bool(re.fullmatch(pattern, 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 redirect_if_logged_in(*args, **kwargs):
def decorator(view_func): def decorator(view_func):
@wraps(view_func) @wraps(view_func)
@@ -112,6 +127,19 @@ def redirect_if_logged_in(*args, **kwargs):
return decorator return decorator
def redirect_to_own(view_func):
@wraps(view_func)
def wrapper(username, *args, **kwargs):
user = get_active_user()
if username.lower() != user.username:
view_args = dict(request.view_args)
view_args.pop('username', None)
new_args = {**view_args, 'username': user.username}
return redirect(url_for(request.endpoint, **new_args))
return view_func(username, *args, **kwargs)
return wrapper
def login_required(view_func): def login_required(view_func):
@wraps(view_func) @wraps(view_func)
def wrapper(*args, **kwargs): def wrapper(*args, **kwargs):
@@ -174,6 +202,53 @@ def get_prefers_theme():
return session['theme'] return session['theme']
def anonymize_user(user_id):
deleted_user = Users.find({'username': 'deleteduser'})
from ..models import Threads, Posts
from ..lib.babycode import sanitize
threads = Threads.findall({'user_id': user_id})
posts = Posts.findall({'user_id': user_id})
revs_q = """SELECT DISTINCT m.revision_id FROM mentions m
WHERE m.mentioned_user_id = ?"""
mentioned_revs = db.query(revs_q, int(user_id))
with db.transaction():
for thread in threads:
thread.update({'user_id': int(deleted_user.id)})
for post in posts:
post.update({'user_id': int(deleted_user.id)})
revs = {}
for rev in mentioned_revs:
ph = PostHistory.find({'id': int(rev['revision_id'])})
ms = Mentions.findall({
'mentioned_user_id': int(user_id),
'revision_id': int(rev['revision_id'])
})
data = {
'text': sanitize(ph.original_markup),
'mentions': ms,
}
data['mentions'] = sorted(data['mentions'], key=lambda x: int(x.end_index), reverse=True)
revs[rev['revision_id']] = data
for rev_id, data in revs.items():
text = data['text']
for mention in data['mentions']:
text = text[:mention.start_index] + '@deleteduser' + text[mention.end_index:]
mention.delete()
res = babycode_to_html(text)
ph = PostHistory.find({'id': int(rev_id)})
ph.update({
'original_markup': text.unescape(),
'content': res.result,
})
@bp.get("/log_in") @bp.get("/log_in")
@redirect_if_logged_in(".page", username = lambda: get_active_user().username) @redirect_if_logged_in(".page", username = lambda: get_active_user().username)
def log_in(): def log_in():
@@ -184,7 +259,7 @@ def log_in():
@redirect_if_logged_in(".page", username = lambda: get_active_user().username) @redirect_if_logged_in(".page", username = lambda: get_active_user().username)
def log_in_post(): def log_in_post():
target_user = Users.find({ target_user = Users.find({
"username": request.form['username'] "username": request.form['username'].lower()
}) })
if not target_user: if not target_user:
flash("Incorrect username or password.", InfoboxKind.ERROR) flash("Incorrect username or password.", InfoboxKind.ERROR)
@@ -239,7 +314,7 @@ def sign_up_post():
flash("Invalid username.", InfoboxKind.ERROR) flash("Invalid username.", InfoboxKind.ERROR)
return redirect(url_for("users.sign_up", key=key)) 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: if user_exists:
flash(f"Username '{username}' is already taken.", InfoboxKind.ERROR) flash(f"Username '{username}' is already taken.", InfoboxKind.ERROR)
return redirect(url_for("users.sign_up", key=key)) return redirect(url_for("users.sign_up", key=key))
@@ -254,8 +329,14 @@ def sign_up_post():
hashed = digest(password) hashed = digest(password)
if username.lower() != username:
display_name = username
else:
display_name = ''
new_user = Users.create({ new_user = Users.create({
"username": username, "username": username,
'display_name': display_name,
"password_hash": hashed, "password_hash": hashed,
"permission": PermissionLevel.GUEST.value, "permission": PermissionLevel.GUEST.value,
}) })
@@ -279,22 +360,22 @@ def sign_up_post():
@bp.get("/<username>") @bp.get("/<username>")
def page(username): def page(username):
target_user = Users.find({"username": username}) target_user = Users.find({"username": username.lower()})
if not target_user:
abort(404)
return render_template("users/user.html", target_user = target_user) return render_template("users/user.html", target_user = target_user)
@bp.get("/<username>/settings") @bp.get("/<username>/settings")
@login_required @login_required
@redirect_to_own
def settings(username): def settings(username):
target_user = Users.find({'username': username})
if target_user.id != get_active_user().id:
return redirect('.settings', username = get_active_user().username)
return render_template('users/settings.html') return render_template('users/settings.html')
@bp.post('/<username>/settings') @bp.post('/<username>/settings')
@login_required @login_required
@redirect_to_own
def settings_form(username): def settings_form(username):
# we silently ignore the passed username # we silently ignore the passed username
# and grab the correct user from the session # and grab the correct user from the session
@@ -311,10 +392,16 @@ def settings_form(username):
status = request.form.get('status', default="")[:100] status = request.form.get('status', default="")[:100]
original_sig = request.form.get('signature', default='').strip() original_sig = request.form.get('signature', default='').strip()
if original_sig: if original_sig:
rendered_sig = babycode_to_html(original_sig) rendered_sig = babycode_to_html(original_sig).result
else: else:
rendered_sig = '' rendered_sig = ''
session['subscribe_by_default'] = request.form.get('subscribe_by_default', default='off') == 'on' session['subscribe_by_default'] = request.form.get('subscribe_by_default', default='off') == 'on'
display_name = request.form.get('display_name', default='')
if not validate_display_name(display_name):
flash('Invalid display name.', InfoboxKind.ERROR)
return redirect('.settings', username=user.username)
old_dn = user.display_name
user.update({ user.update({
'status': status, 'status': status,
@@ -322,13 +409,29 @@ def settings_form(username):
'signature_rendered': rendered_sig, 'signature_rendered': rendered_sig,
'signature_format_version': BABYCODE_VERSION, 'signature_format_version': BABYCODE_VERSION,
'signature_markup_language': 'babycode', 'signature_markup_language': 'babycode',
'display_name': display_name,
}) })
if old_dn != display_name:
# re-parse mentions
q = """SELECT DISTINCT m.revision_id FROM mentions m
JOIN post_history ph ON m.revision_id = ph.id
JOIN posts p ON p.current_revision_id = ph.id
WHERE m.mentioned_user_id = ?"""
mentions = db.query(q, int(user.id))
with db.transaction():
for mention in mentions:
rev = PostHistory.find({'id': int(mention['revision_id'])})
parsed_content = babycode_to_html(rev.original_markup).result
rev.update({'content': parsed_content})
flash('Settings updated.', InfoboxKind.INFO) flash('Settings updated.', InfoboxKind.INFO)
return redirect(url_for('.settings', username=user.username)) return redirect(url_for('.settings', username=user.username))
@bp.post('/<username>/set_avatar') @bp.post('/<username>/set_avatar')
@login_required @login_required
@redirect_to_own
def set_avatar(username): def set_avatar(username):
user = get_active_user() user = get_active_user()
if user.is_guest(): if user.is_guest():
@@ -372,6 +475,7 @@ def set_avatar(username):
@bp.post('/<username>/change_password') @bp.post('/<username>/change_password')
@login_required @login_required
@redirect_to_own
def change_password(username): def change_password(username):
user = get_active_user() user = get_active_user()
password = request.form.get('new_password') password = request.form.get('new_password')
@@ -394,6 +498,7 @@ def change_password(username):
@bp.post('/<username>/clear_avatar') @bp.post('/<username>/clear_avatar')
@login_required @login_required
@redirect_to_own
def clear_avatar(username): def clear_avatar(username):
user = get_active_user() user = get_active_user()
if user.is_default_avatar(): if user.is_default_avatar():
@@ -486,11 +591,9 @@ def guest_user(user_id):
@bp.get("/<username>/inbox") @bp.get("/<username>/inbox")
@login_required @login_required
@redirect_to_own
def inbox(username): def inbox(username):
user = get_active_user() user = get_active_user()
if username != user.username:
return redirect(url_for(".inbox", username = user.username))
new_posts = [] new_posts = []
subscription = Subscriptions.find({"user_id": user.id}) subscription = Subscriptions.find({"user_id": user.id})
all_subscriptions = None all_subscriptions = None
@@ -628,16 +731,14 @@ def reset_link_login_form(key):
@bp.get('/<username>/invite-links/') @bp.get('/<username>/invite-links/')
@login_required @login_required
@redirect_to_own
def invite_links(username): def invite_links(username):
target_user = Users.find({ target_user = Users.find({
'username': username 'username': username.lower()
}) })
if not target_user or not target_user.can_invite(): if not target_user or not target_user.can_invite():
return redirect(url_for('.page', username=username)) return redirect(url_for('.page', username=username))
if target_user.username != get_active_user().username:
return redirect(url_for('.invite_links', username=target_user.username))
invites = InviteKeys.findall({ invites = InviteKeys.findall({
'created_by': target_user.id 'created_by': target_user.id
}) })
@@ -647,15 +748,13 @@ def invite_links(username):
@bp.post('/<username>/invite-links/create') @bp.post('/<username>/invite-links/create')
@login_required @login_required
@redirect_to_own
def create_invite_link(username): def create_invite_link(username):
target_user = Users.find({ target_user = Users.find({
'username': username 'username': username.lower()
}) })
if not target_user or not target_user.can_invite(): if not target_user or not target_user.can_invite():
return redirect(url_for('.page', username=username)) return redirect(url_for('.page', username=username.lower()))
if target_user.username != get_active_user().username:
return redirect(url_for('.invite_links', username=target_user.username))
invite = InviteKeys.create({ invite = InviteKeys.create({
'created_by': target_user.id, 'created_by': target_user.id,
@@ -667,15 +766,13 @@ def create_invite_link(username):
@bp.post('/<username>/invite-links/revoke') @bp.post('/<username>/invite-links/revoke')
@login_required @login_required
@redirect_to_own
def revoke_invite_link(username): def revoke_invite_link(username):
target_user = Users.find({ target_user = Users.find({
'username': username 'username': username.lower()
}) })
if not target_user or not target_user.can_invite(): if not target_user or not target_user.can_invite():
return redirect(url_for('.page', username=username)) return redirect(url_for('.page', username=username.lower()))
if target_user.username != get_active_user().username:
return redirect(url_for('.invite_links', username=target_user.username))
invite = InviteKeys.find({ invite = InviteKeys.find({
'key': request.form.get('key'), 'key': request.form.get('key'),
@@ -694,10 +791,9 @@ def revoke_invite_link(username):
@bp.get('/<username>/bookmarks') @bp.get('/<username>/bookmarks')
@login_required @login_required
@redirect_to_own
def bookmarks(username): def bookmarks(username):
target_user = Users.find({'username': username}) target_user = get_active_user()
if not target_user or target_user.username != get_active_user().username:
return redirect(url_for('.bookmarks', username=get_active_user().username))
collections = target_user.get_bookmark_collections() collections = target_user.get_bookmark_collections()
@@ -706,10 +802,40 @@ def bookmarks(username):
@bp.get('/<username>/bookmarks/collections') @bp.get('/<username>/bookmarks/collections')
@login_required @login_required
@redirect_to_own
def bookmark_collections(username): def bookmark_collections(username):
target_user = Users.find({'username': username}) target_user = get_active_user()
if not target_user or target_user.username != get_active_user().username:
return redirect(url_for('.bookmark_collections', username=get_active_user().username))
collections = target_user.get_bookmark_collections() collections = target_user.get_bookmark_collections()
return render_template('users/bookmark_collections.html', collections=collections) return render_template('users/bookmark_collections.html', collections=collections)
@bp.get('/<username>/delete-account')
@login_required
@redirect_to_own
def delete_page(username):
target_user = get_active_user()
return render_template('users/delete_page.html')
@bp.post('/<username>/delete-account')
@login_required
@redirect_to_own
def delete_page_confirm(username):
target_user = get_active_user()
password = request.form.get('password', default='')
if not verify(target_user.password_hash, password):
flash('Incorrect password.', InfoboxKind.ERROR)
return redirect(url_for('.delete_page', username=username))
anonymize_user(target_user.id)
sessions = Sessions.findall({'user_id': int(target_user.id)})
for session_obj in sessions:
session_obj.delete()
session.clear()
target_user.delete()
return redirect(url_for('topics.all_topics'))

View File

@@ -132,6 +132,15 @@ SCHEMA = [
"user_id" REFERENCES users(id) ON DELETE CASCADE "user_id" REFERENCES users(id) ON DELETE CASCADE
)""", )""",
"""CREATE TABLE IF NOT EXISTS "mentions" (
"id" INTEGER NOT NULL PRIMARY KEY,
"revision_id" REFERENCES post_history(id) ON DELETE CASCADE,
"mentioned_user_id" REFERENCES users(id) ON DELETE CASCADE,
"start_index" INTEGER NOT NULL,
"end_index" INTEGER NOT NULL,
"original_mention_text" TEXT NOT NULL
)""",
# INDEXES # INDEXES
"CREATE INDEX IF NOT EXISTS idx_post_history_post_id ON post_history(post_id)", "CREATE INDEX IF NOT EXISTS idx_post_history_post_id ON post_history(post_id)",
"CREATE INDEX IF NOT EXISTS idx_posts_thread ON posts(thread_id, created_at, id)", "CREATE INDEX IF NOT EXISTS idx_posts_thread ON posts(thread_id, created_at, id)",
@@ -155,6 +164,9 @@ SCHEMA = [
"CREATE INDEX IF NOT EXISTS idx_bookmarked_threads_collection ON bookmarked_threads(collection_id)", "CREATE INDEX IF NOT EXISTS idx_bookmarked_threads_collection ON bookmarked_threads(collection_id)",
"CREATE INDEX IF NOT EXISTS idx_bookmarked_threads_thread ON bookmarked_threads(thread_id)", "CREATE INDEX IF NOT EXISTS idx_bookmarked_threads_thread ON bookmarked_threads(thread_id)",
"CREATE INDEX IF NOT EXISTS idx_mentioned_user ON mentions(mentioned_user_id)",
"CREATE INDEX IF NOT EXISTS idx_mention_revision_id ON mentions(revision_id)",
] ]
def create(): def create():

View File

@@ -128,7 +128,7 @@
<code class="inline-code">[img=https://forum.poto.cafe/avatars/default.webp]the Python logo with a cowboy hat[/img]</code> <code class="inline-code">[img=https://forum.poto.cafe/avatars/default.webp]the Python logo with a cowboy hat[/img]</code>
{{ '[img=/static/avatars/default.webp]the Python logo with a cowboy hat[/img]' | babycode | safe }} {{ '[img=/static/avatars/default.webp]the Python logo with a cowboy hat[/img]' | babycode | safe }}
</p> </p>
<p>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.</p> <p>The attribute is the image URL. The text inside the tag will become the image's alt text.</p>
<p>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.</p> <p>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.</p>
<p>Multiple images attached to a post can be clicked to open a dialog to view them.</p> <p>Multiple images attached to a post can be clicked to open a dialog to view them.</p>
</section> </section>
@@ -157,13 +157,22 @@
{{ list | babycode | safe }} {{ list | babycode | safe }}
</section> </section>
<section class="babycode-guide-section"> <section class="babycode-guide-section">
<h2 id="spoilers">Spoilers</h2> <h2 id="spoilers">Spoilers</h2>
{% set spoiler = "[spoiler=Major Metal Gear Spoilers]Snake dies[/spoiler]" %} {% set spoiler = "[spoiler=Major Metal Gear Spoilers]Snake dies[/spoiler]" %}
<p>You can make a section collapsible by using the <code class="inline-code">[spoiler]</code> tag:</p> <p>You can make a section collapsible by using the <code class="inline-code">[spoiler]</code> tag:</p>
{{ ("[code]\n%s[/code]" % spoiler) | babycode | safe }} {{ ("[code]\n%s[/code]" % spoiler) | babycode | safe }}
Will produce: Will produce:
{{ spoiler | babycode | safe }} {{ spoiler | babycode | safe }}
All other tags are supported inside spoilers. All other tags are supported inside spoilers.
</section>
<section class="babycode-guide-section">
<h2 id="mentions">Mentioning users</h2>
<p>You can mention users by their username (<em>not</em> their display name) by using <code class="inline-code">@username</code>. A user's username is always shown below their avatar and display name on their posts and their user page.</p>
<p>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 <code class="inline-code">@</code> symbol if they don't:</p>
<a class="mention" href="#mentions" title="@user-without-display-name">@user-without-display-name</a>
<a class="mention display" href="#mentions" title="@user-with-display-name">User with display name</a>
<a class="mention display me" href="#mentions" title="@your-username">Your display name</a>
<p>Mentioning a user does not notify them. It is simply a way to link to their profile in your posts.</p>
</section> </section>
{% endset %} {% endset %}
{{ sections | safe }} {{ sections | safe }}

View File

@@ -28,5 +28,4 @@
</footer> </footer>
</bitty-6-0> </bitty-6-0>
<script src="{{ "/static/js/ui.js" | cachebust }}"></script> <script src="{{ "/static/js/ui.js" | cachebust }}"></script>
<script src="{{ "/static/js/date-fmt.js" | cachebust }}"></script>
</body> </body>

View File

@@ -0,0 +1,8 @@
{% extends 'base.html' %}
{% block title %}not found{% endblock %}
{% block content %}
<div class="darkbg settings-container">
<h1 class="thread-title">404 Not Found</h1>
<p>The requested URL does not exist.</p>
</div>
{% endblock %}

View File

@@ -75,7 +75,7 @@
{% endmacro %} {% endmacro %}
{% macro timestamp(unix_ts) -%} {% macro timestamp(unix_ts) -%}
<span class="timestamp" data-utc="{{ unix_ts }}">{{ unix_ts | ts_datetime('%Y-%m-%d %H:%M')}} <abbr title="Server Time">ST</abbr></span> <span class="timestamp" data-utc="{{ unix_ts }}" data-init="convertTimestamps">{{ unix_ts | ts_datetime('%Y-%m-%d %H:%M')}} <abbr title="Server Time">ST</abbr></span>
{%- endmacro %} {%- endmacro %}
{% macro babycode_editor_component(ta_name, ta_placeholder="Post body", optional=False, prefill="", banned_tags=[]) %} {% macro babycode_editor_component(ta_name, ta_placeholder="Post body", optional=False, prefill="", banned_tags=[]) %}
@@ -105,7 +105,7 @@
<div> <div>
<ul class="horizontal"> <ul class="horizontal">
{% for tag in banned_tags | unique %} {% for tag in banned_tags | unique %}
<li><code class="inline-code">[{{ tag }}]</code></li> <li><code class="inline-code">{{ tag }}</code></li>
{% endfor %} {% endfor %}
</ul> </ul>
</div> </div>
@@ -121,7 +121,7 @@
{% macro babycode_editor_form(ta_name, prefill = "", cancel_url="", endpoint="") %} {% macro babycode_editor_form(ta_name, prefill = "", cancel_url="", endpoint="") %}
{% set save_button_text = "Post reply" if not cancel_url else "Save" %} {% set save_button_text = "Post reply" if not cancel_url else "Save" %}
<form class="post-edit-form" method="post" action={{ endpoint }}> <form class="post-edit-form" method="post" {%- if endpoint %}action={{ endpoint }}{% endif %}>
{{babycode_editor_component(ta_name, prefill = prefill)}} {{babycode_editor_component(ta_name, prefill = prefill)}}
{% if not cancel_url %} {% if not cancel_url %}
<span> <span>
@@ -156,7 +156,8 @@
<a href="{{ url_for("users.page", username=post['username']) }}" style="display: contents;"> <a href="{{ url_for("users.page", username=post['username']) }}" style="display: contents;">
<img src="{{ post['avatar_path'] }}" class="avatar"> <img src="{{ post['avatar_path'] }}" class="avatar">
</a> </a>
<a href="{{ url_for("users.page", username=post['username']) }}" class="username-link">{{ post['username'] }}</a> <a href="{{ url_for("users.page", username=post['username']) }}" class="username-link">{{ post['display_name'] or post['username'] }}</a>
<em><abbr title="Mention">@{{ post.username }}</abbr></em>
{% if post['status'] %} {% if post['status'] %}
<em class="user-status">{{ post['status'] }}</em> <em class="user-status">{{ post['status'] }}</em>
{% endif %} {% endif %}
@@ -205,7 +206,7 @@
{% endif %} {% endif %}
{% if show_reply %} {% if show_reply %}
{% set qtext = "[url=%s]%s said:[/url]" | format(post_permalink, post['username']) %} {% set qtext = "@%s [url=%s]said:[/url]" | format(post['username'], post_permalink) %}
{% set reply_text = "%s\n[quote]\n%s\n[/quote]\n" | format(qtext, post['original_markup']) %} {% set reply_text = "%s\n[quote]\n%s\n[/quote]\n" | format(qtext, post['original_markup']) %}
<button data-send="addQuote" value="{{ reply_text }}" class="reply-button">Quote</button> <button data-send="addQuote" value="{{ reply_text }}" class="reply-button">Quote</button>
{% endif %} {% endif %}

View File

@@ -11,7 +11,7 @@
{% endif %} {% endif %}
{% else %} {% else %}
{% with user = get_active_user() %} {% with user = get_active_user() %}
Welcome, <a href="{{ url_for("users.page", username = user.username) }}">{{user.username}}</a> Welcome, <a href="{{ url_for("users.page", username = user.username) }}">{{user.get_readable_name()}}</a>
<ul class="horizontal"> <ul class="horizontal">
<li><a href="{{ url_for("users.settings", username = user.username) }}">Settings</a></li> <li><a href="{{ url_for("users.settings", username = user.username) }}">Settings</a></li>
<li><a href="{{ url_for("users.inbox", username = user.username) }}">Inbox</a></li> <li><a href="{{ url_for("users.inbox", username = user.username) }}">Inbox</a></li>

View File

@@ -9,7 +9,7 @@
<label for="title">Title</label> <label for="title">Title</label>
<input name="title" id="title" type="text" required autocomplete="off" placeholder="Required" value="{{ current.title }}"><br> <input name="title" id="title" type="text" required autocomplete="off" placeholder="Required" value="{{ current.title }}"><br>
<label for="body">Body</label> <label for="body">Body</label>
{{ babycode_editor_component('body', ta_placeholder='MOTD body (required)', banned_tags=['img', 'spoiler'], prefill=current.body_original_markup) }} {{ babycode_editor_component('body', ta_placeholder='MOTD body (required)', banned_tags=MOTD_BANNED_TAGS, prefill=current.body_original_markup) }}
<input type="submit" value="Save"> <input type="submit" value="Save">
<input class="critical" type="submit" formaction="{{ url_for('mod.motd_delete') }}" value="Delete MOTD" formnovalidate {{"disabled" if not current else ""}}> <input class="critical" type="submit" formaction="{{ url_for('mod.motd_delete') }}" value="Delete MOTD" formnovalidate {{"disabled" if not current else ""}}>
</form> </form>

View File

@@ -56,12 +56,12 @@
</span> </span>
&bullet; &bullet;
<span> <span>
Started by <a href="{{ url_for("users.page", username=thread['started_by']) }}">{{ thread['started_by'] }}</a> on {{ timestamp(thread['created_at']) }} Started by <a href="{{ url_for("users.page", username=thread['started_by']) }}">{{ thread['started_by_display_name'] or thread['started_by'] }}</a> on {{ timestamp(thread['created_at']) }}
</span> </span>
</span> </span>
</span> </span>
<span> <span>
Latest post by <a href="{{ url_for("users.page", username=thread['latest_post_username']) }}">{{ thread['latest_post_username'] }}</a> Latest post by <a href="{{ url_for("users.page", username=thread['latest_post_username']) }}">{{ thread['latest_post_display_name'] or thread['latest_post_username'] }}</a>
on <a href="{{ url_for("threads.thread", slug=thread['slug'], after=thread['latest_post_id']) }}">on {{ timestamp(thread['latest_post_created_at']) }}</a>: on <a href="{{ url_for("threads.thread", slug=thread['slug'], after=thread['latest_post_id']) }}">on {{ timestamp(thread['latest_post_created_at']) }}</a>:
</span> </span>
<span class="thread-info-post-preview"> <span class="thread-info-post-preview">

View File

@@ -26,12 +26,12 @@
{{ topic['description'] }} {{ topic['description'] }}
{% if topic['latest_thread_username'] %} {% if topic['latest_thread_username'] %}
<span> <span>
Latest thread: <a href="{{ url_for("threads.thread", slug=topic['latest_thread_slug'])}}">{{topic['latest_thread_title']}}</a> by <a href="{{url_for("users.page", username=topic['latest_thread_username'])}}">{{topic['latest_thread_username']}}</a> on {{ timestamp(topic['latest_thread_created_at']) }} Latest thread: <a href="{{ url_for("threads.thread", slug=topic['latest_thread_slug'])}}">{{topic['latest_thread_title']}}</a> by <a href="{{url_for("users.page", username=topic['latest_thread_username'])}}">{{topic['latest_thread_display_name'] or topic['latest_thread_username']}}</a> on {{ timestamp(topic['latest_thread_created_at']) }}
</span> </span>
{% if topic['id'] in active_threads %} {% if topic['id'] in active_threads %}
{% with thread=active_threads[topic['id']] %} {% with thread=active_threads[topic['id']] %}
<span> <span>
Latest post in: <a href="{{ url_for("threads.thread", slug=thread['thread_slug'])}}">{{ thread['thread_title'] }}</a> by <a href="{{ url_for("users.page", username=thread['username'])}}">{{ thread['username'] }}</a> at <a href="{{ get_post_url(thread.post_id, _anchor=true) }}">{{ timestamp(thread['post_created_at']) }}</a> Latest post in: <a href="{{ url_for("threads.thread", slug=thread['thread_slug'])}}">{{ thread['thread_title'] }}</a> by <a href="{{ url_for("users.page", username=thread['username'])}}">{{ thread['display_name'] or thread['username'] }}</a> at <a href="{{ get_post_url(thread.post_id, _anchor=true) }}">{{ timestamp(thread['post_created_at']) }}</a>
</span> </span>
{% endwith %} {% endwith %}
{% endif %} {% endif %}

View File

@@ -0,0 +1,15 @@
{% extends 'base.html' %}
{% block title %}delete confirmation{% endblock %}
{% block content %}
<div class="darkbg login-container">
<h1>Confirm account deletion</h1>
<p>Are you sure you want to delete your account on {{ config.SITE_NAME }}? <strong>This action is irreversible.</strong> Your posts and threads will remain accessible to preserve history but will be de-personalized, showing up as authored by a system user. Posts that @mention you will also mention the system user instead.</p>
<p>If you wish for any and all content relating to you to be removed, you will have to ask {{ config.SITE_NAME }}'s administrators separately.</p>
<p>If you are sure, please confirm your current password below.</p>
<form method="post">
<label for="password">Confirm password</label>
<input type="password" id="password" name="password" required autocomplete="current-password">
<input class="critical" type="submit" value="Delete account">
</form>
</div>
{% endblock %}

View File

@@ -26,6 +26,8 @@
<option value='activity' {{ 'selected' if session['sort_by'] == 'activity' else '' }}>Latest activity</option> <option value='activity' {{ 'selected' if session['sort_by'] == 'activity' else '' }}>Latest activity</option>
<option value='thread' {{ 'selected' if session['sort_by'] == 'thread' else '' }}>Thread creation date</option> <option value='thread' {{ 'selected' if session['sort_by'] == 'thread' else '' }}>Thread creation date</option>
</select> </select>
<label for='display_name'>Display name</label>
<input type='text' id='display_name' name='display_name' value='{{ active_user.display_name }}' pattern="(?:[\w!#$%^*\(\)\-_=+\[\]\{\}\|;:,.?\s]{3,50})?" title='3-50 characters, no @, no <>, no &' placeholder='Optional. Will be shown in place of username.' autocomplete='off'></input>
<label for='status'>Status</label> <label for='status'>Status</label>
<input type='text' id='status' name='status' value='{{ active_user.status }}' maxlength=100 placeholder='Will be shown under your name. Max 100 characters.'> <input type='text' id='status' name='status' value='{{ active_user.status }}' maxlength=100 placeholder='Will be shown under your name. Max 100 characters.'>
<label for='babycode-content'>Signature</label> <label for='babycode-content'>Signature</label>
@@ -41,5 +43,8 @@
<input type="password" id="new_password2" name="new_password2" pattern="(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[\W_])(?!.*\s).{10,}" title="10+ chars with: 1 uppercase, 1 lowercase, 1 number, 1 special char, and no spaces" required autocomplete="new-password"><br> <input type="password" id="new_password2" name="new_password2" pattern="(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[\W_])(?!.*\s).{10,}" title="10+ chars with: 1 uppercase, 1 lowercase, 1 number, 1 special char, and no spaces" required autocomplete="new-password"><br>
<input class="warn" type="submit" value="Change password"> <input class="warn" type="submit" value="Change password">
</form> </form>
<div>
<a class="linkbutton critical" href="{{ url_for('users.delete_page', username=active_user.username) }}">Delete account</a>
</div>
</div> </div>
{% endblock %} {% endblock %}

View File

@@ -4,7 +4,7 @@
<div class="darkbg login-container"> <div class="darkbg login-container">
<h1>Sign up</h1> <h1>Sign up</h1>
{% if inviter %} {% if inviter %}
<p>You have been invited by <a href="{{ url_for('users.page', username=inviter.username) }}">{{ inviter.username }}</a> to join {{ config.SITE_NAME }}. Create an identity below.</p> <p>You have been invited by <a href="{{ url_for('users.page', username=inviter.username) }}">{{ inviter.get_readable_name() }}</a> to join {{ config.SITE_NAME }}. Create an identity below.</p>
{% endif %} {% endif %}
<form method="post"> <form method="post">
{% if key %} {% if key %}

View File

@@ -1,9 +1,9 @@
{% from 'common/macros.html' import timestamp %} {% from 'common/macros.html' import timestamp %}
{% extends 'base.html' %} {% extends 'base.html' %}
{% block title %}{{ target_user.username }}'s profile{% endblock %} {% block title %}{{ target_user.get_readable_name() }}'s profile{% endblock %}
{% block content %} {% block content %}
<div class="darkbg"> <div class="darkbg">
<h1 class="thread-title"><i>{{ target_user.username }}</i>'s profile</h1> <h1 class="thread-title"><i>{{ target_user.get_readable_name() }}</i>'s profile</h1>
{% if active_user.id == target_user.id %} {% if active_user.id == target_user.id %}
<div class="user-actions"> <div class="user-actions">
<a class="linkbutton" href="{{ url_for("users.settings", username = active_user.username) }}">Settings</a> <a class="linkbutton" href="{{ url_for("users.settings", username = active_user.username) }}">Settings</a>
@@ -45,7 +45,8 @@
<div class="user-page-usercard"> <div class="user-page-usercard">
<div class="usercard-inner"> <div class="usercard-inner">
<img class="avatar" src="{{ target_user.get_avatar_url() }}"> <img class="avatar" src="{{ target_user.get_avatar_url() }}">
<strong class="big">{{ target_user.username }}</strong> <strong class="big">{{ target_user.get_readable_name() }}</strong>
<em><abbr title="Mention">@{{ target_user.username }}</abbr></em>
{% if target_user.status %} {% if target_user.status %}
<em class="user-status">{{ target_user.status }}</em> <em class="user-status">{{ target_user.status }}</em>
{% endif %} {% endif %}
@@ -65,7 +66,7 @@
<li>Latest started thread: <a href="{{ url_for("threads.thread", slug = stats.latest_thread_slug) }}">{{ stats.latest_thread_title }}</a> <li>Latest started thread: <a href="{{ url_for("threads.thread", slug = stats.latest_thread_slug) }}">{{ stats.latest_thread_title }}</a>
{% endif %} {% endif %}
{% if stats.inviter_username %} {% if stats.inviter_username %}
<li>Invited by <a href="{{ url_for('users.page', username=stats.inviter_username) }}">{{ stats.inviter_username }}</a></li> <li>Invited by <a href="{{ url_for('users.page', username=stats.inviter_username) }}">{{ stats.inviter_display_name or stats.inviter_username }}</a></li>
{% endif %} {% endif %}
</ul> </ul>
{% endwith %} {% endwith %}

View File

@@ -814,13 +814,15 @@ p {
} }
.login-container > * { .login-container > * {
width: 40%; width: 70%;
margin: auto; margin: auto;
max-width: 1000px;
} }
.settings-container > * { .settings-container > * {
width: 40%; width: 70%;
margin: auto; margin: auto;
max-width: 1000px;
} }
.avatar-form { .avatar-form {
@@ -1409,3 +1411,24 @@ footer {
font-weight: bold; font-weight: bold;
font-size: larger; font-size: larger;
} }
a.mention, a.mention:visited {
display: inline-block;
color: white;
background-color: rgb(135.1928346457, 145.0974015748, 123.0025984252);
padding: 5px;
border-radius: 4px;
text-decoration: none;
}
a.mention.display, a.mention:visited.display {
text-decoration: underline;
text-decoration-style: dashed;
}
a.mention.me, a.mention:visited.me {
background-color: rgb(123.0025984252, 145.0974015748, 143.9545669291);
border: 1px dashed;
}
a.mention:hover, a.mention:visited:hover {
background-color: rgb(229.84, 231.92, 227.28);
color: black;
}

View File

@@ -814,13 +814,15 @@ p {
} }
.login-container > * { .login-container > * {
width: 40%; width: 70%;
margin: auto; margin: auto;
max-width: 1000px;
} }
.settings-container > * { .settings-container > * {
width: 40%; width: 70%;
margin: auto; margin: auto;
max-width: 1000px;
} }
.avatar-form { .avatar-form {
@@ -1410,6 +1412,27 @@ footer {
font-size: larger; font-size: larger;
} }
a.mention, a.mention:visited {
display: inline-block;
color: #e6e6e6;
background-color: rgb(96.95, 81.55, 96.95);
padding: 5px;
border-radius: 8px;
text-decoration: none;
}
a.mention.display, a.mention:visited.display {
text-decoration: underline;
text-decoration-style: dashed;
}
a.mention.me, a.mention:visited.me {
background-color: rgb(96.95, 89.25, 81.55);
border: 1px dashed;
}
a.mention:hover, a.mention:visited:hover {
background-color: #ae6bae;
color: #e6e6e6;
}
#topnav { #topnav {
margin-bottom: 10px; margin-bottom: 10px;
border: 10px solid rgb(40, 40, 40); border: 10px solid rgb(40, 40, 40);

View File

@@ -814,13 +814,15 @@ p {
} }
.login-container > * { .login-container > * {
width: 60%; width: 70%;
margin: auto; margin: auto;
max-width: 1000px;
} }
.settings-container > * { .settings-container > * {
width: 60%; width: 70%;
margin: auto; margin: auto;
max-width: 1000px;
} }
.avatar-form { .avatar-form {
@@ -1410,6 +1412,27 @@ footer {
font-size: larger; font-size: larger;
} }
a.mention, a.mention:visited {
display: inline-block;
color: white;
background-color: rgb(155.8907865169, 93.2211235955, 76.5092134831);
padding: 3px;
border-radius: 16px;
text-decoration: none;
}
a.mention.display, a.mention:visited.display {
text-decoration: underline;
text-decoration-style: dashed;
}
a.mention.me, a.mention:visited.me {
background-color: rgb(99.4880898876, 155.8907865169, 76.5092134831);
border: 1px dashed;
}
a.mention:hover, a.mention:visited:hover {
background-color: rgb(231.56, 212.36, 207.24);
color: black;
}
#topnav { #topnav {
border-top-left-radius: 16px; border-top-left-radius: 16px;
border-top-right-radius: 16px; border-top-right-radius: 16px;

View File

@@ -1,5 +1,6 @@
const bookmarkMenuHrefTemplate = '/hyperapi/bookmarks-dropdown'; const bookmarkMenuHrefTemplate = '/hyperapi/bookmarks-dropdown';
const previewEndpoint = '/api/babycode-preview'; const previewEndpoint = '/api/babycode-preview';
const userEndpoint = '/api/current-user';
const delay = ms => {return new Promise(resolve => setTimeout(resolve, ms))} const delay = ms => {return new Promise(resolve => setTimeout(resolve, ms))}
@@ -141,7 +142,7 @@ export default class {
} }
} }
#previousMarkup = ''; #previousMarkup = null;
async babycodePreview(ev, el) { async babycodePreview(ev, el) {
if (ev.sender.classList.contains('active')) { if (ev.sender.classList.contains('active')) {
return; return;
@@ -151,11 +152,17 @@ export default class {
const previewContainer = el.querySelector('#babycode-preview-container'); const previewContainer = el.querySelector('#babycode-preview-container');
const ta = document.getElementById('babycode-content'); const ta = document.getElementById('babycode-content');
const markup = ta.value.trim(); const markup = ta.value.trim();
if (markup === '' || markup === this.#previousMarkup) { if (markup === '') {
previewErrorsContainer.textContent = 'Type something!'; previewErrorsContainer.textContent = 'Type something!';
previewContainer.textContent = ''; previewContainer.textContent = '';
this.#previousMarkup = '';
return; return;
} }
if (markup === this.#previousMarkup) {
return;
}
const bannedTags = JSON.parse(document.getElementById('babycode-banned-tags').value); const bannedTags = JSON.parse(document.getElementById('babycode-banned-tags').value);
this.#previousMarkup = markup; this.#previousMarkup = markup;
@@ -246,4 +253,27 @@ export default class {
el.scrollIntoView(); el.scrollIntoView();
el.focus(); el.focus();
} }
convertTimestamps(ev, el) {
const timestamp = el.getInt('utc');
if (!isNaN(timestamp)) {
const date = new Date(timestamp * 1000);
el.textContent = date.toLocaleString();
}
}
#currentUsername = undefined;
async highlightMentions(ev, el) {
if (this.#currentUsername === undefined) {
const userInfo = await this.api.getJSON(userEndpoint);
if (!userInfo.value) {
return;
}
this.#currentUsername = userInfo.value.user.username;
}
if (el.getString('username') === this.#currentUsername) {
el.classList.add('me');
}
}
} }

View File

@@ -1,10 +0,0 @@
document.addEventListener("DOMContentLoaded", () => {
const timestampSpans = document.getElementsByClassName("timestamp");
for (let timestampSpan of timestampSpans) {
const timestamp = parseInt(timestampSpan.dataset.utc);
if (!isNaN(timestamp)) {
const date = new Date(timestamp * 1000);
timestampSpan.textContent = date.toLocaleString();
}
}
})

View File

@@ -82,7 +82,6 @@
quoteButton.textContent = "Quote fragment" quoteButton.textContent = "Quote fragment"
quoteButton.className = "reduced" quoteButton.className = "reduced"
quotePopover.appendChild(quoteButton); quotePopover.appendChild(quoteButton);
document.body.appendChild(quotePopover); document.body.appendChild(quotePopover);
return quoteButton; return quoteButton;
} }
@@ -98,7 +97,7 @@
if (ta.value.trim() !== "") { if (ta.value.trim() !== "") {
ta.value += "\n" ta.value += "\n"
} }
ta.value += `[url=${postPermalink}]${authorUsername} said:[/url]\n[quote]< :scissors: > ${document.getSelection().toString()} < :scissors: >[/quote]\n`; ta.value += `@${authorUsername} [url=${postPermalink}]said:[/url]\n[quote]< :scissors: > ${document.getSelection().toString()} < :scissors: >[/quote]\n`;
ta.scrollIntoView() ta.scrollIntoView()
ta.focus(); ta.focus();

View File

@@ -51,7 +51,8 @@ $BIGGER_PADDING: 30px !default;
$PAGE_SIDE_MARGIN: 100px !default; $PAGE_SIDE_MARGIN: 100px !default;
$SETTINGS_WIDTH: 40% !default; $SETTINGS_WIDTH: 70% !default;
$SETTINGS_MAX_WIDTH: 1000px !default;
// ************** // **************
// BORDERS // BORDERS
@@ -654,15 +655,19 @@ $pagebutton_min_width: $BIG_PADDING !default;
} }
$login_container_width: $SETTINGS_WIDTH !default; $login_container_width: $SETTINGS_WIDTH !default;
$login_container_max_width: $SETTINGS_MAX_WIDTH !default;
.login-container > * { .login-container > * {
width: $login_container_width; width: $login_container_width;
margin: auto; margin: auto;
max-width: $login_container_max_width;
} }
$settings_container_width: $SETTINGS_WIDTH !default; $settings_container_width: $SETTINGS_WIDTH !default;
$settings_container_max_width: $SETTINGS_MAX_WIDTH !default;
.settings-container > * { .settings-container > * {
width: $settings_container_width; width: $settings_container_width;
margin: auto; margin: auto;
max-width: $settings_container_max_width
} }
$avatar_form_padding: $BIG_PADDING $ZERO_PADDING !default; $avatar_form_padding: $BIG_PADDING $ZERO_PADDING !default;
@@ -1369,3 +1374,36 @@ $motd_content_padding_right: 25% !default;
font-weight: bold; font-weight: bold;
font-size: larger; font-size: larger;
} }
$mention_font_color: $DEFAULT_FONT_COLOR_INVERSE !default;
$mention_font_color_hover: $DEFAULT_FONT_COLOR !default;
$mention_background_color: $DARK_2 !default;
$mention_background_color_me: color.adjust($DARK_2, $hue: 90) !default;
$mention_background_color_hover: $LIGHT_2 !default;
$mention_border_me: 1px dashed;
$mention_padding: $SMALL_PADDING !default;
$mention_border_radius: $DEFAULT_BORDER_RADIUS !default;
a.mention, a.mention:visited {
display: inline-block;
color: $mention_font_color;
background-color: $mention_background_color;
padding: $mention_padding;
border-radius: $mention_border_radius;
text-decoration: none;
&.display {
text-decoration: underline;
text-decoration-style: dashed;
}
&.me {
background-color: $mention_background_color_me;
border: $mention_border_me;
}
&:hover {
background-color: $mention_background_color_hover;
color: $mention_font_color_hover;
}
}

View File

@@ -80,6 +80,8 @@ $br: 8px;
$tab_button_active_color: #8a5584, $tab_button_active_color: #8a5584,
$bookmarks_dropdown_background_color: $lightish_accent, $bookmarks_dropdown_background_color: $lightish_accent,
$mention_font_color: $fc,
); );
#topnav { #topnav {

View File

@@ -17,7 +17,6 @@ $br: 16px;
$thread_locked_border: 1px solid black, $thread_locked_border: 1px solid black,
$motd_border: 1px solid black, $motd_border: 1px solid black,
$SETTINGS_WIDTH: 60%,
$PAGE_SIDE_MARGIN: 50px, $PAGE_SIDE_MARGIN: 50px,
$link_color: black, $link_color: black,