From ddef2d341ac1c6f6f9f993754577bcb675f3eb76 Mon Sep 17 00:00:00 2001 From: Charmander <~@charmander.me> Date: Mon, 21 Oct 2024 20:07:58 -0700 Subject: [PATCH 1/8] Normalize private message recipient names before echoing in form field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prevents a potential attack like: ``` [https://www.weasyl.com/notes/compose?recipient=admin](/notes/compose?recipient=admin%09%09%09…%09[%3b]evil) ``` (with or without a semicolon), where evidence of an unintended recipient is pushed outside the visible field with no visual indication in typical browsers (if a revealing selection is never made). Also prevents Unicode and ASCII confusion attacks outside of lowercase + digit character set. --- weasyl/controllers/interaction.py | 2 +- weasyl/define.py | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/weasyl/controllers/interaction.py b/weasyl/controllers/interaction.py index 72a08ca73..599df86f6 100644 --- a/weasyl/controllers/interaction.py +++ b/weasyl/controllers/interaction.py @@ -146,7 +146,7 @@ def notes_compose_get_(request): return Response(define.webpage(request.userid, "note/compose.html", [ # Recipient - form.recipient.strip(), + "; ".join(define.get_sysname_list(form.recipient)), profile.select_myself(request.userid), ])) diff --git a/weasyl/define.py b/weasyl/define.py index d184fe424..894c8dce0 100644 --- a/weasyl/define.py +++ b/weasyl/define.py @@ -434,9 +434,12 @@ def get_userids(usernames): return ret +def get_sysname_list(s: str) -> list[str]: + return list(filter(None, map(get_sysname, s.split(";")))) + + def get_userid_list(target): - usernames = target.split(";") - return [userid for userid in get_userids(usernames).values() if userid != 0] + return [userid for userid in get_userids(get_sysname_list(target)).values() if userid != 0] def get_ownerid(submitid=None, charid=None, journalid=None): From bb904fc21088e7a4673f22433c11502ca31a6758 Mon Sep 17 00:00:00 2001 From: Charmander <~@charmander.me> Date: Fri, 18 Oct 2024 18:06:09 -0700 Subject: [PATCH 2/8] Replace an `` with a ` Inbox Outbox From 955fc5a17ec366098b49c0282ae70441f7502dc6 Mon Sep 17 00:00:00 2001 From: Charmander <~@charmander.me> Date: Mon, 21 Oct 2024 16:20:06 -0700 Subject: [PATCH 3/8] cleanup: Remove unused message folder filters from queries `user_folder` and `other_folder` are always 0. --- libweasyl/models/tables.py | 1 + weasyl/note.py | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/libweasyl/models/tables.py b/libweasyl/models/tables.py index e2f6584aa..f70f7f90e 100644 --- a/libweasyl/models/tables.py +++ b/libweasyl/models/tables.py @@ -376,6 +376,7 @@ def default_fkey(*args, **kwargs): Column('noteid', Integer(), primary_key=True, nullable=False), Column('userid', Integer(), nullable=False), Column('otherid', Integer(), nullable=False), + # TODO: delete unused columns after deployment of change that removes them from queries Column('user_folder', Integer(), nullable=False, server_default='0'), Column('other_folder', Integer(), nullable=False, server_default='0'), Column('title', String(length=100), nullable=False), diff --git a/weasyl/note.py b/weasyl/note.py index ac6d49000..75e7aca8f 100644 --- a/weasyl/note.py +++ b/weasyl/note.py @@ -21,7 +21,7 @@ def select_inbox(userid, limit, backid=None, nextid=None, filter=[]): SELECT ms.noteid, ms.userid, ps.username, ms.title, ms.unixtime, ms.settings FROM message ms INNER JOIN profile ps USING (userid) - WHERE (ms.otherid, ms.other_folder) = (%(recipient)s, 0) + WHERE ms.otherid = %(recipient)s AND ms.settings !~ 'r' """] @@ -55,7 +55,7 @@ def select_outbox(userid, limit, backid=None, nextid=None, filter=[]): SELECT ms.noteid, ms.otherid, pr.username, ms.title, ms.unixtime FROM message ms INNER JOIN profile pr ON ms.otherid = pr.userid - WHERE (ms.userid, ms.user_folder) = (%(sender)s, 0) + WHERE ms.userid = %(sender)s AND ms.settings !~ 's' """] From 98506dca32ae2cd0418fb283b09953f187230d7c Mon Sep 17 00:00:00 2001 From: Charmander <~@charmander.me> Date: Mon, 21 Oct 2024 18:41:45 -0700 Subject: [PATCH 4/8] Clean up common page navigation component - remove unused template parameter `count_limit` - meaningful HTML: use ` From d10d4497d8ef154cebe766ef952a19ff653b0aaf Mon Sep 17 00:00:00 2001 From: Charmander <~@charmander.me> Date: Mon, 21 Oct 2024 19:11:30 -0700 Subject: [PATCH 5/8] Use common pagination for private message lists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds counts and hides buttons when there’s no page in that direction, improving usability. --- assets/scss/components/_page-navigation.scss | 5 + weasyl/controllers/interaction.py | 40 +++--- weasyl/note.py | 122 +++++++++++++------ weasyl/templates/common/page_navigation.html | 4 +- weasyl/templates/note/message_list.html | 85 ++++++------- 5 files changed, 153 insertions(+), 103 deletions(-) diff --git a/assets/scss/components/_page-navigation.scss b/assets/scss/components/_page-navigation.scss index 4f6d8f391..568eacce6 100644 --- a/assets/scss/components/_page-navigation.scss +++ b/assets/scss/components/_page-navigation.scss @@ -5,3 +5,8 @@ margin-left: auto; } } + +// XXX: temporary hack for private message list +.pages-layout-left { + float: left; +} diff --git a/weasyl/controllers/interaction.py b/weasyl/controllers/interaction.py index 599df86f6..51e84e694 100644 --- a/weasyl/controllers/interaction.py +++ b/weasyl/controllers/interaction.py @@ -4,7 +4,7 @@ from weasyl.controllers.decorators import login_required, token_checked from weasyl.error import WeasylError from weasyl import ( - define, favorite, followuser, frienduser, ignoreuser, note, profile) + define, favorite, followuser, frienduser, ignoreuser, note, pagination, profile) # User interactivity functions @@ -114,27 +114,31 @@ def notes_(request): raise WeasylError("vouchRequired") form = request.web_input(folder="inbox", filter="", backid="", nextid="") + + if form.folder == "inbox": + select_list = note.select_inbox + select_count = note.select_inbox_count + elif form.folder == "outbox": + select_list = note.select_outbox + select_count = note.select_outbox_count + else: + raise WeasylError("unknownMessageFolder") + backid = int(form.backid) if form.backid else None nextid = int(form.nextid) if form.nextid else None filter_ = define.get_userid_list(form.filter) - if form.folder == "inbox": - return Response(define.webpage(request.userid, "note/message_list.html", [ - # Folder - "inbox", - # Private messages - note.select_inbox(request.userid, 50, backid=backid, nextid=nextid, filter=filter_), - ])) - - if form.folder == "outbox": - return Response(define.webpage(request.userid, "note/message_list.html", [ - # Folder - "outbox", - # Private messages - note.select_outbox(request.userid, 50, backid=backid, nextid=nextid, filter=filter_), - ])) - - raise WeasylError("unknownMessageFolder") + result = pagination.PaginatedResult( + select_list, select_count, "noteid", f"/notes?folder={form.folder}&%s", + request.userid, filter=filter_, + backid=backid, + nextid=nextid, + count_limit=note.COUNT_LIMIT, + ) + return Response(define.webpage(request.userid, "note/message_list.html", ( + form.folder, + result, + ))) @login_required diff --git a/weasyl/note.py b/weasyl/note.py index 75e7aca8f..91c4aee38 100644 --- a/weasyl/note.py +++ b/weasyl/note.py @@ -16,26 +16,54 @@ """ -def select_inbox(userid, limit, backid=None, nextid=None, filter=[]): - statement = [""" - SELECT ms.noteid, ms.userid, ps.username, ms.title, ms.unixtime, ms.settings +PAGE_SIZE = 50 +COUNT_LIMIT = 1000 + + +def _select_inbox_query(with_backid: bool, with_nextid: bool, with_filter: bool): + yield """ FROM message ms INNER JOIN profile ps USING (userid) WHERE ms.otherid = %(recipient)s AND ms.settings !~ 'r' - """] + """ + + if with_filter: + yield " AND ms.userid = ANY (%(filter)s)" + + if with_backid: + yield " AND ms.noteid > %(backid)s" + elif with_nextid: + yield " AND ms.noteid < %(nextid)s" + + +def _select_outbox_query(with_backid: bool, with_nextid: bool, with_filter: bool): + yield """ + FROM message ms + INNER JOIN profile pr ON ms.otherid = pr.userid + WHERE ms.userid = %(sender)s + AND ms.settings !~ 's' + """ - if filter: - statement.append(" AND ms.userid = ANY (%(filter)s)") + if with_filter: + yield " AND ms.otherid = ANY (%(filter)s)" - if backid: - statement.append(" AND ms.noteid > %(backid)s ORDER BY ms.noteid") - elif nextid: - statement.append(" AND ms.noteid < %(nextid)s ORDER BY ms.noteid DESC") - else: - statement.append(" ORDER BY ms.noteid DESC") + if with_backid: + yield " AND ms.noteid > %(backid)s" + elif with_nextid: + yield " AND ms.noteid < %(nextid)s" - statement.append(" LIMIT %(limit)s") + +def select_inbox(userid, *, limit: None, backid, nextid, filter): + statement = "".join(( + "SELECT ms.noteid, ms.userid, ps.username, ms.title, ms.unixtime, ms.settings", + *_select_inbox_query( + with_backid=backid is not None, + with_nextid=nextid is not None, + with_filter=bool(filter), + ), + f" ORDER BY ms.noteid {'DESC' if backid is None else ''} LIMIT {PAGE_SIZE}", + )) query = [{ "noteid": i.noteid, @@ -45,31 +73,21 @@ def select_inbox(userid, limit, backid=None, nextid=None, filter=[]): "title": i.title, "unixtime": i.unixtime, } for i in d.engine.execute( - "".join(statement), recipient=userid, filter=filter, backid=backid, nextid=nextid, limit=limit)] + statement, recipient=userid, filter=filter, backid=backid, nextid=nextid)] - return list(reversed(query)) if backid else query + return query if backid is None else query[::-1] -def select_outbox(userid, limit, backid=None, nextid=None, filter=[]): - statement = [""" - SELECT ms.noteid, ms.otherid, pr.username, ms.title, ms.unixtime - FROM message ms - INNER JOIN profile pr ON ms.otherid = pr.userid - WHERE ms.userid = %(sender)s - AND ms.settings !~ 's' - """] - - if filter: - statement.append(" AND ms.otherid = ANY (%(filter)s)") - - if backid: - statement.append(" AND ms.noteid > %(backid)s ORDER BY ms.noteid") - elif nextid: - statement.append(" AND ms.noteid < %(nextid)s ORDER BY ms.noteid DESC") - else: - statement.append(" ORDER BY ms.noteid DESC") - - statement.append(" LIMIT %(limit)s") +def select_outbox(userid, *, limit: None, backid, nextid, filter): + statement = "".join(( + "SELECT ms.noteid, ms.otherid, pr.username, ms.title, ms.unixtime", + *_select_outbox_query( + with_backid=backid is not None, + with_nextid=nextid is not None, + with_filter=bool(filter), + ), + f" ORDER BY ms.noteid {'DESC' if backid is None else ''} LIMIT {PAGE_SIZE}", + )) query = [{ "noteid": i.noteid, @@ -78,9 +96,37 @@ def select_outbox(userid, limit, backid=None, nextid=None, filter=[]): "title": i.title, "unixtime": i.unixtime, } for i in d.engine.execute( - "".join(statement), sender=userid, filter=filter, backid=backid, nextid=nextid, limit=limit)] - - return list(reversed(query)) if backid else query + statement, sender=userid, filter=filter, backid=backid, nextid=nextid)] + + return query if backid is None else query[::-1] + + +def select_inbox_count(userid, *, backid, nextid, filter): + statement = "".join(( + "SELECT count(*) FROM (SELECT ", + *_select_inbox_query( + with_backid=backid is not None, + with_nextid=nextid is not None, + with_filter=bool(filter), + ), + f" LIMIT {COUNT_LIMIT}) t", + )) + return d.engine.scalar( + statement, recipient=userid, filter=filter, backid=backid, nextid=nextid) + + +def select_outbox_count(userid, *, backid, nextid, filter): + statement = "".join(( + "SELECT count(*) FROM (SELECT ", + *_select_outbox_query( + with_backid=backid is not None, + with_nextid=nextid is not None, + with_filter=bool(filter), + ), + f" LIMIT {COUNT_LIMIT}) t", + )) + return d.engine.scalar( + statement, sender=userid, filter=filter, backid=backid, nextid=nextid) def select_view(userid, noteid): diff --git a/weasyl/templates/common/page_navigation.html b/weasyl/templates/common/page_navigation.html index e88c4d247..04fa5781e 100644 --- a/weasyl/templates/common/page_navigation.html +++ b/weasyl/templates/common/page_navigation.html @@ -1,6 +1,6 @@ -$def with (result) +$def with (result, layout="pages-layout-split") $# paginated navigation buttons using a PaginatedResult -