From da5ce0e54bc73f37567151e3c6c6f0c5385a55aa Mon Sep 17 00:00:00 2001 From: Mattia Succurro Date: Fri, 13 Jun 2025 15:28:31 +0200 Subject: [PATCH 01/73] Fixed a critical bug that prevented database initialization --- CHANGELOG.md | 6 +- alembic/versions/c7c2d5b8f71c_.py | 50 ----------- alembic/versions/fc9d1a0dc94e_.py | 132 ------------------------------ freak/__init__.py | 7 +- freak/cli.py | 8 +- 5 files changed, 16 insertions(+), 187 deletions(-) delete mode 100644 alembic/versions/c7c2d5b8f71c_.py delete mode 100644 alembic/versions/fc9d1a0dc94e_.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d01566..02dd2ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 0.3.1 + +- Fixed a critical bug that prevented database initialization + ## 0.3.0 - Initial commit @@ -11,7 +15,7 @@ - Admins can remove reported posts - Upvotes and downvotes -## 0.2.0 and earlier +## 0.2 and earlier *Releases before 0.3.0 are lost for good, and for a good reason.* diff --git a/alembic/versions/c7c2d5b8f71c_.py b/alembic/versions/c7c2d5b8f71c_.py deleted file mode 100644 index ce29710..0000000 --- a/alembic/versions/c7c2d5b8f71c_.py +++ /dev/null @@ -1,50 +0,0 @@ -"""empty message - -Revision ID: c7c2d5b8f71c -Revises: fc9d1a0dc94e -Create Date: 2025-06-12 09:21:17.960836 - -""" -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision: str = 'c7c2d5b8f71c' -down_revision: Union[str, None] = 'fc9d1a0dc94e' -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - """Upgrade schema.""" - # ### commands auto generated by Alembic - please adjust! ### - op.add_column('freak_comment', sa.Column('removed_at', sa.DateTime(), nullable=True)) - op.add_column('freak_comment', sa.Column('removed_by_id', sa.BigInteger(), nullable=True)) - op.add_column('freak_comment', sa.Column('removed_reason', sa.SmallInteger(), nullable=True)) - op.create_foreign_key('user_banner_id', 'freak_comment', 'freak_user', ['removed_by_id'], ['id']) - op.add_column('freak_post', sa.Column('removed_at', sa.DateTime(), nullable=True)) - op.add_column('freak_post', sa.Column('removed_by_id', sa.BigInteger(), nullable=True)) - op.add_column('freak_post', sa.Column('removed_reason', sa.SmallInteger(), nullable=True)) - op.create_foreign_key('user_banner_id', 'freak_post', 'freak_user', ['removed_by_id'], ['id']) - op.add_column('freak_user', sa.Column('banned_reason', sa.SmallInteger(), server_default=sa.text('0'), nullable=True)) - op.alter_column('freak_user', 'ban_reason', new_column_name='banned_message') - # ### end Alembic commands ### - - -def downgrade() -> None: - """Downgrade schema.""" - # ### commands auto generated by Alembic - please adjust! ### - op.alter_column('freak_user', 'banned_message', new_column_name='ban_reason') - op.drop_column('freak_user', 'banned_reason') - op.drop_constraint('user_banner_id', 'freak_post', type_='foreignkey') - op.drop_column('freak_post', 'removed_reason') - op.drop_column('freak_post', 'removed_by_id') - op.drop_column('freak_post', 'removed_at') - op.drop_constraint('user_banner_id', 'freak_comment', type_='foreignkey') - op.drop_column('freak_comment', 'removed_reason') - op.drop_column('freak_comment', 'removed_by_id') - op.drop_column('freak_comment', 'removed_at') - # ### end Alembic commands ### diff --git a/alembic/versions/fc9d1a0dc94e_.py b/alembic/versions/fc9d1a0dc94e_.py deleted file mode 100644 index a1aee26..0000000 --- a/alembic/versions/fc9d1a0dc94e_.py +++ /dev/null @@ -1,132 +0,0 @@ -"""empty message - -Revision ID: fc9d1a0dc94e -Revises: -Create Date: 2025-06-11 18:23:07.871471 - -""" -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa -from sqlalchemy.dialects import postgresql - -# revision identifiers, used by Alembic. -revision: str = 'fc9d1a0dc94e' -down_revision: Union[str, None] = None -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - """Upgrade schema.""" - # ### commands auto generated by Alembic - please adjust! ### - op.alter_column('freak_comment', 'author_id', - existing_type=sa.BIGINT(), - nullable=True) - op.drop_index('idx_16573_comment_parent_comment_id', table_name='freak_comment') - op.drop_index('idx_16573_comment_parent_post_id', table_name='freak_comment') - op.drop_index('idx_16573_comment_pub_date', table_name='freak_comment') - op.drop_index('idx_16573_comment_user_id', table_name='freak_comment') - op.create_index(op.f('ix_freak_comment_created_at'), 'freak_comment', ['created_at'], unique=False) - op.alter_column('freak_post', 'author_id', - existing_type=sa.BIGINT(), - nullable=True) - op.drop_index('idx_16568_post_community_id', table_name='freak_post') - op.drop_index('idx_16568_post_pub_date', table_name='freak_post') - op.drop_index('idx_16568_post_user_id', table_name='freak_post') - op.add_column('freak_postreport', sa.Column('created_ip', sa.String(length=64), nullable=False)) - op.alter_column('freak_postreport', 'target_type', - existing_type=sa.SMALLINT(), - nullable=False) - op.alter_column('freak_postreport', 'target_id', - existing_type=sa.BIGINT(), - nullable=False) - op.alter_column('freak_postreport', 'reason_code', - existing_type=sa.SMALLINT(), - nullable=False) - op.alter_column('freak_topic', 'created_at', - existing_type=postgresql.TIMESTAMP(), - nullable=False, - existing_server_default=sa.text('CURRENT_TIMESTAMP')) - op.drop_index('idx_16563_community_created_on', table_name='freak_topic') - op.drop_index('idx_16563_community_name', table_name='freak_topic') - op.create_index(op.f('ix_freak_topic_created_at'), 'freak_topic', ['created_at'], unique=False) - op.create_unique_constraint(None, 'freak_topic', ['name']) - op.alter_column('freak_user', 'joined_at', - existing_type=postgresql.TIMESTAMP(), - nullable=False, - existing_server_default=sa.text('CURRENT_TIMESTAMP')) - op.alter_column('freak_user', 'is_administrator', - existing_type=sa.BOOLEAN(), - nullable=False, - existing_server_default=sa.text('false')) - op.alter_column('freak_user', 'is_disabled_by_user', - existing_type=sa.BOOLEAN(), - nullable=False, - existing_server_default=sa.text('false')) - op.alter_column('freak_user', 'karma', - existing_type=sa.BIGINT(), - nullable=False, - existing_server_default=sa.text('0')) - op.drop_index('idx_16578_user_join_date', table_name='freak_user') - op.drop_index('idx_16578_user_username', table_name='freak_user') - op.create_unique_constraint(None, 'freak_user', ['username']) - # ### end Alembic commands ### - - -def downgrade() -> None: - """Downgrade schema.""" - # ### commands auto generated by Alembic - please adjust! ### - op.drop_constraint(None, 'freak_user', type_='unique') - op.create_index('idx_16578_user_username', 'freak_user', ['username'], unique=True) - op.create_index('idx_16578_user_join_date', 'freak_user', ['joined_at'], unique=False) - op.alter_column('freak_user', 'karma', - existing_type=sa.BIGINT(), - nullable=True, - existing_server_default=sa.text('0')) - op.alter_column('freak_user', 'is_disabled_by_user', - existing_type=sa.BOOLEAN(), - nullable=True, - existing_server_default=sa.text('false')) - op.alter_column('freak_user', 'is_administrator', - existing_type=sa.BOOLEAN(), - nullable=True, - existing_server_default=sa.text('false')) - op.alter_column('freak_user', 'joined_at', - existing_type=postgresql.TIMESTAMP(), - nullable=True, - existing_server_default=sa.text('CURRENT_TIMESTAMP')) - op.drop_constraint(None, 'freak_topic', type_='unique') - op.drop_index(op.f('ix_freak_topic_created_at'), table_name='freak_topic') - op.create_index('idx_16563_community_name', 'freak_topic', ['name'], unique=True) - op.create_index('idx_16563_community_created_on', 'freak_topic', ['created_at'], unique=False) - op.alter_column('freak_topic', 'created_at', - existing_type=postgresql.TIMESTAMP(), - nullable=True, - existing_server_default=sa.text('CURRENT_TIMESTAMP')) - op.alter_column('freak_postreport', 'reason_code', - existing_type=sa.SMALLINT(), - nullable=True) - op.alter_column('freak_postreport', 'target_id', - existing_type=sa.BIGINT(), - nullable=True) - op.alter_column('freak_postreport', 'target_type', - existing_type=sa.SMALLINT(), - nullable=True) - op.drop_column('freak_postreport', 'created_ip') - op.create_index('idx_16568_post_user_id', 'freak_post', ['author_id'], unique=False) - op.create_index('idx_16568_post_pub_date', 'freak_post', ['created_at'], unique=False) - op.create_index('idx_16568_post_community_id', 'freak_post', ['topic_id'], unique=False) - op.alter_column('freak_post', 'author_id', - existing_type=sa.BIGINT(), - nullable=False) - op.drop_index(op.f('ix_freak_comment_created_at'), table_name='freak_comment') - op.create_index('idx_16573_comment_user_id', 'freak_comment', ['author_id'], unique=False) - op.create_index('idx_16573_comment_pub_date', 'freak_comment', ['created_at'], unique=False) - op.create_index('idx_16573_comment_parent_post_id', 'freak_comment', ['parent_post_id'], unique=False) - op.create_index('idx_16573_comment_parent_comment_id', 'freak_comment', ['parent_comment_id'], unique=False) - op.alter_column('freak_comment', 'author_id', - existing_type=sa.BIGINT(), - nullable=False) - # ### end Alembic commands ### diff --git a/freak/__init__.py b/freak/__init__.py index 4903958..12e0837 100644 --- a/freak/__init__.py +++ b/freak/__init__.py @@ -3,17 +3,18 @@ from sqlite3 import ProgrammingError import warnings from flask import ( - Flask, abort, flash, g, jsonify, redirect, render_template, + Flask, g, redirect, render_template, request, send_from_directory, url_for ) -import datetime, time, re, os, sys, string, json, html, dotenv +import os +import dotenv from flask_login import LoginManager from flask_wtf.csrf import CSRFProtect from sqlalchemy import select from werkzeug.routing import BaseConverter from sassutils.wsgi import SassMiddleware -__version__ = '0.3.0' +__version__ = '0.3.1' APP_BASE_DIR = os.path.dirname(os.path.dirname(__file__)) diff --git a/freak/cli.py b/freak/cli.py index 0426802..06b9727 100644 --- a/freak/cli.py +++ b/freak/cli.py @@ -2,16 +2,22 @@ import argparse import os +import subprocess from . import __version__ as version - +from .models import db def make_parser(): parser = argparse.ArgumentParser() parser.add_argument('--version', '-v', action='version', version=version) + parser.add_argument('--upgrade', '-U', action='store_true', help='create or upgrade schema') return parser def main(): args = make_parser().parse_args() + if args.upgrade: + db.metadata.create_all() + subprocess.Popen(['alembic', 'upgrade', 'head']).wait() + print('Schema upgraded!') print(f'Visit ') From 9d3fd0477538f4e9f163bb46315cd2c48209eac3 Mon Sep 17 00:00:00 2001 From: Mattia Succurro Date: Fri, 13 Jun 2025 15:31:41 +0200 Subject: [PATCH 02/73] bug?? --- freak/cli.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/freak/cli.py b/freak/cli.py index 06b9727..9fd7930 100644 --- a/freak/cli.py +++ b/freak/cli.py @@ -3,6 +3,8 @@ import argparse import os import subprocess + +from sqlalchemy import create_engine from . import __version__ as version from .models import db @@ -15,7 +17,7 @@ def make_parser(): def main(): args = make_parser().parse_args() if args.upgrade: - db.metadata.create_all() + db.metadata.create_all(create_engine(os.getenv('DATABASE_URL'))) subprocess.Popen(['alembic', 'upgrade', 'head']).wait() print('Schema upgraded!') From cde1683834b775240b4e6c41b7b518f3cb1b995c Mon Sep 17 00:00:00 2001 From: Mattia Succurro Date: Fri, 13 Jun 2025 15:34:31 +0200 Subject: [PATCH 03/73] fix default values in .models --- freak/models.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/freak/models.py b/freak/models.py index b43055c..2bb8f26 100644 --- a/freak/models.py +++ b/freak/models.py @@ -83,7 +83,7 @@ PostUpvote = Table( Base.metadata, Column('post_id', BigInteger, ForeignKey('freak_post.id'), primary_key=True), Column('voter_id', BigInteger, ForeignKey('freak_user.id'), primary_key=True), - Column('is_downvote', Boolean, server_default=text('0')) + Column('is_downvote', Boolean, server_default=text('false')) ) class User(BaseModel): @@ -98,8 +98,8 @@ class User(BaseModel): gdpr_birthday = Column(Date, nullable=False) joined_at = Column(DateTime, server_default=func.current_timestamp(), nullable=False) joined_ip = Column(String(64), default=get_remote_addr, nullable=False) - is_administrator = Column(Boolean, server_default=text('0'), nullable=False) - is_disabled_by_user = Column(Boolean, server_default=text('0'), nullable=False) + is_administrator = Column(Boolean, server_default=text('false'), nullable=False) + is_disabled_by_user = Column(Boolean, server_default=text('false'), nullable=False) karma = Column(BigInteger, server_default=text('0'), nullable=False) legacy_id = Column(BigInteger, nullable=True) # TODO add pronouns and biography (upcoming 0.4) From 83dd4b7f051c49a66091557a99aec296241ee00c Mon Sep 17 00:00:00 2001 From: Mattia Succurro Date: Fri, 13 Jun 2025 15:43:53 +0200 Subject: [PATCH 04/73] remove dead code --- freak/__init__.py | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/freak/__init__.py b/freak/__init__.py index 12e0837..a9cd278 100644 --- a/freak/__init__.py +++ b/freak/__init__.py @@ -18,15 +18,7 @@ __version__ = '0.3.1' APP_BASE_DIR = os.path.dirname(os.path.dirname(__file__)) -dotenv.load_dotenv(os.path.join(APP_BASE_DIR, '.env')) - -correct_database_url = os.environ["DATABASE_URL"] - -def fix_database_url(): - if os.getenv('DATABASE_URL') != correct_database_url: - warnings.warn('mod_wsgi got the database wrong!', RuntimeWarning) - app.config['SQLALCHEMY_DATABASE_URI'] = correct_database_url - +dotenv.load_dotenv() app = Flask(__name__) app.secret_key = os.getenv('SECRET_KEY') From 0a4de8c048314cabf90c23efd2b68a37235c783d Mon Sep 17 00:00:00 2001 From: Mattia Succurro Date: Fri, 13 Jun 2025 15:45:48 +0200 Subject: [PATCH 05/73] typo --- freak/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/freak/__init__.py b/freak/__init__.py index a9cd278..0a0f45f 100644 --- a/freak/__init__.py +++ b/freak/__init__.py @@ -22,7 +22,7 @@ dotenv.load_dotenv() app = Flask(__name__) app.secret_key = os.getenv('SECRET_KEY') -app.config['SQLALCHEMY_DATABASE_URI'] = correct_database_url +app.config['SQLALCHEMY_DATABASE_URI'] = os.getenv('DATABASE_URL') app.config['JSONIFY_PRETTYPRINT_REGULAR'] = False from .models import db, User, Post From e5e15c6b839f3b1eb1b1a5b26f453ec84b7ae2c3 Mon Sep 17 00:00:00 2001 From: Mattia Succurro Date: Fri, 13 Jun 2025 15:54:06 +0200 Subject: [PATCH 06/73] fix Docker build --- docker-run.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docker-run.sh b/docker-run.sh index 760d11e..331cbe1 100644 --- a/docker-run.sh +++ b/docker-run.sh @@ -3,7 +3,8 @@ start-app() { [[ ! -d /opt/live-app ]] && exit 1 cd /usr/src/app - cp -rv /opt/live-app/{freak,pyproject.toml,.env,docker-run.sh} ./ + cp -rv /opt/live-app/{freak,pyproject.toml,docker-run.sh} ./ + cp -v /opt/live-app/.env.prod .env pip install -e . flask --app freak run --host=0.0.0.0 } From 22524c5920a26a5132d4b7bab1bfd5dbea78d05b Mon Sep 17 00:00:00 2001 From: Mattia Succurro Date: Sat, 14 Jun 2025 02:47:41 +0200 Subject: [PATCH 07/73] fixed administrator users not being able to create +guilds --- CHANGELOG.md | 4 ++++ freak/__init__.py | 2 +- freak/models.py | 7 +++++-- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 02dd2ae..157b5af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 0.3.2 + +- Fixed administrator users not being able to create +guilds + ## 0.3.1 - Fixed a critical bug that prevented database initialization diff --git a/freak/__init__.py b/freak/__init__.py index 0a0f45f..9c32442 100644 --- a/freak/__init__.py +++ b/freak/__init__.py @@ -14,7 +14,7 @@ from sqlalchemy import select from werkzeug.routing import BaseConverter from sassutils.wsgi import SassMiddleware -__version__ = '0.3.1' +__version__ = '0.3.2' APP_BASE_DIR = os.path.dirname(os.path.dirname(__file__)) diff --git a/freak/models.py b/freak/models.py index 2bb8f26..99872f4 100644 --- a/freak/models.py +++ b/freak/models.py @@ -163,8 +163,11 @@ class User(BaseModel): db.session.execute(update(User).where(User.id == self.id).values(karma = self.karma + points)) db.session.commit() - def can_create_community(self): - return self.karma > 15 + def can_create_guild(self): + return self.karma > 15 or self.is_administrator + + ## deprecated alias! + can_create_community = can_create_guild def handle(self): return f'@{self.username}' From 1ad2aad08d00a36ee4579061fec6bbcdd9e6464d Mon Sep 17 00:00:00 2001 From: Mattia Succurro Date: Sat, 14 Jun 2025 02:59:47 +0200 Subject: [PATCH 08/73] css fixes --- freak/static/sass/content.sass | 1 + freak/static/sass/layout.sass | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/freak/static/sass/content.sass b/freak/static/sass/content.sass index 5b0b4b3..56354ec 100644 --- a/freak/static/sass/content.sass +++ b/freak/static/sass/content.sass @@ -29,6 +29,7 @@ blockquote ul margin: 4px 0 padding: 0 + padding-inline-start: 1.5em > li margin: 0 diff --git a/freak/static/sass/layout.sass b/freak/static/sass/layout.sass index ff7e034..58d7b49 100644 --- a/freak/static/sass/layout.sass +++ b/freak/static/sass/layout.sass @@ -145,7 +145,8 @@ ul.message-options list-style: none padding: 0 font-size: smaller - margin-bottom: -4px + .comment-frame & + margin-bottom: -4px .post-frame margin-left: 3em From 382d06961bd6e82a46676e5fafac5f9c67119561 Mon Sep 17 00:00:00 2001 From: Mattia Succurro Date: Sat, 14 Jun 2025 12:09:47 +0200 Subject: [PATCH 09/73] minor style changes --- freak/static/sass/base.sass | 12 ++++++------ freak/static/sass/layout.sass | 6 ++++++ freak/templates/feed.html | 4 ++-- freak/templates/macros/nav.html | 8 ++++---- freak/templates/singlepost.html | 15 ++++++++------- freak/website/frontpage.py | 6 ++++-- 6 files changed, 30 insertions(+), 21 deletions(-) diff --git a/freak/static/sass/base.sass b/freak/static/sass/base.sass index 71772ab..97b65a6 100644 --- a/freak/static/sass/base.sass +++ b/freak/static/sass/base.sass @@ -5,10 +5,11 @@ box-sizing: border-box \:root + --accent: #ff7300 + --light-text-primary: #181818 --light-text-alt: #444 --light-border: #999 - --light-accent: #ff7300 --light-success: #73af00 --light-error: #e04433 --light-canvas: #eaecee @@ -18,17 +19,19 @@ --dark-text-primary: #e8e8e8 --dark-text-alt: #c0cad3 --dark-border: #777 - --dark-accent: #ff7300 --dark-success: #93cf00 --dark-error: #e04433 --dark-canvas: #0a0a0e --dark-background: #181a21 --dark-bg-sharp: #080808 + // the following are DEPRECATED // + --light-accent: var(--accent) + --dark-accent: var(--accent) + --text-primary: var(--light-text-primary) --text-alt: var(--light-text-alt) --border: var(--light-border) - --accent: var(--light-accent) --success: var(--light-success) --error: var(--light-error) --canvas: var(--light-canvas) @@ -40,7 +43,6 @@ --text-primary: var(--dark-text-primary) --text-alt: var(--dark-text-alt) --border: var(--dark-border) - --accent: var(--dark-accent) --success: var(--dark-success) --error: var(--dark-error) --canvas: var(--dark-canvas) @@ -51,7 +53,6 @@ body.color-scheme-light --text-primary: var(--light-text-primary) --text-alt: var(--light-text-alt) --border: var(--light-border) - --accent: var(--light-accent) --success: var(--light-success) --error: var(--light-error) --canvas: var(--light-canvas) @@ -62,7 +63,6 @@ body.color-scheme-dark --text-primary: var(--dark-text-primary) --text-alt: var(--dark-text-alt) --border: var(--dark-border) - --accent: var(--dark-accent) --success: var(--dark-success) --error: var(--dark-error) --canvas: var(--dark-canvas) diff --git a/freak/static/sass/layout.sass b/freak/static/sass/layout.sass index 58d7b49..b933e36 100644 --- a/freak/static/sass/layout.sass +++ b/freak/static/sass/layout.sass @@ -103,6 +103,9 @@ aside.card padding: 12px margin: -12px -12px 0 -12px position: relative + a + color: inherit + text-decoration: underline > ul list-style: none margin: 0 @@ -157,6 +160,9 @@ ul.message-options margin-left: 0 margin-right: 3em + .message-options + margin-bottom: 1em + .message-stats position: absolute left: -3em diff --git a/freak/templates/feed.html b/freak/templates/feed.html index 01057a4..87d97d5 100644 --- a/freak/templates/feed.html +++ b/freak/templates/feed.html @@ -20,8 +20,8 @@ {{ nav_top_communities(top_communities) }} {% endif %} - {% if feed_type == 'topic' %} - {% from "macros/nav.html" import nav_topic with context %} + {% if feed_type == 'guild' %} + {% from "macros/nav.html" import nav_guild with context %} {{ nav_topic(topic) }} {% endif %} diff --git a/freak/templates/macros/nav.html b/freak/templates/macros/nav.html index 3a63238..f652f72 100644 --- a/freak/templates/macros/nav.html +++ b/freak/templates/macros/nav.html @@ -1,9 +1,9 @@ -{% macro nav_topic(topic) %} +{% macro nav_guild(gu) %} -{% endmacro %} \ No newline at end of file +{% endmacro %} diff --git a/freak/templates/singlepost.html b/freak/templates/singlepost.html index 20c3260..a4f5955 100644 --- a/freak/templates/singlepost.html +++ b/freak/templates/singlepost.html @@ -40,12 +40,12 @@ {% endif %} {% if current_user.is_administrator and p.report_count() %} - {% call callout() %} + {% call callout('spoiler', 'error') %} {{ p.report_count() }} reports. Take action {% endcall %} {% endif %} {% if p.is_removed %} - {% call callout('delete') %} + {% call callout('delete', 'error') %} This post has been removed {% endcall %} {% endif %} @@ -69,7 +69,7 @@ {{ comment_area(p.url()) }}
    - {% for comment in p.top_level_comments() %} + {% for comment in comments %}
  • {{ single_comment(comment) }} diff --git a/freak/templates/userfeed.html b/freak/templates/userfeed.html index f24f445..ca6b44a 100644 --- a/freak/templates/userfeed.html +++ b/freak/templates/userfeed.html @@ -1,7 +1,7 @@ {% extends "base.html" %} {% from "macros/feed.html" import feed_post, stop_scrolling, no_more_scrolling with context %} {% from "macros/title.html" import title_tag with context %} -{% from "macros/icon.html" import icon, callout with context %} +{% from "macros/icon.html" import icon, big_icon, callout with context %} {% from "macros/nav.html" import nav_user with context %} {% block title %}{{ title_tag(user.handle() + '’s content') }}{% endblock %} @@ -16,9 +16,8 @@ {% endblock %} {% block nav %} - {{ nav_user(user) }} - {% if user == current_user %} - + {% if user.is_active and not user.has_blocked(current_user) %} + {{ nav_user(user) }} {% endif %} {% endblock %} @@ -40,9 +39,10 @@ {% endif %}
{% elif not user.is_active %} + {{ big_icon('ban') }}

{{ user.handle() }} is suspended

{% else %} -

{{ user.handle() }} never posted any content

+

{{ user.handle() }} has never posted any content

{% endif %} {% endblock %} diff --git a/freak/templates/usersettings.html b/freak/templates/usersettings.html index 89cf4dd..de7ef9e 100644 --- a/freak/templates/usersettings.html +++ b/freak/templates/usersettings.html @@ -29,7 +29,7 @@

Appearance

-
    +
    • @@ -37,7 +37,7 @@
-
    +
      {% for color in colors %}
    • {% endfor %} diff --git a/freak/website/detail.py b/freak/website/detail.py index 9a457e8..3de15da 100644 --- a/freak/website/detail.py +++ b/freak/website/detail.py @@ -1,4 +1,5 @@ +from typing import Iterable from flask import Blueprint, abort, flash, request, redirect, render_template, url_for from flask_login import current_user from sqlalchemy import insert, select @@ -6,7 +7,7 @@ from suou import Snowflake from ..utils import is_b32l from ..models import Comment, Guild, db, User, Post -from ..algorithms import user_timeline +from ..algorithms import new_comments, user_timeline bp = Blueprint('detail', __name__) @@ -64,12 +65,17 @@ def post_detail(id: int): else: abort(404) +def comments_of(p: Post) -> Iterable[Comment]: + ## TODO add sort argument + return db.paginate(new_comments(p)) + + @bp.route('/@/comments//', methods=['GET', 'POST']) @bp.route('/@/comments//', methods=['GET', 'POST']) def user_post_detail(username: str, id: int, slug: str = ''): post: Post | None = db.session.execute(select(Post).join(User, User.id == Post.author_id).where(Post.id == id, User.username == username)).scalar() - if post is None or (post.is_removed and post.author != current_user): + if post is None or (post.author and post.author.has_blocked(current_user)) or (post.is_removed and post.author != current_user): abort(404) if post.slug and slug != post.slug: @@ -78,14 +84,14 @@ def user_post_detail(username: str, id: int, slug: str = ''): if request.method == 'POST': single_post_post_hook(post) - return render_template('singlepost.html', p=post) + return render_template('singlepost.html', p=post, comments=comments_of(post)) @bp.route('/+/comments//', methods=['GET', 'POST']) @bp.route('/+/comments//', methods=['GET', 'POST']) def guild_post_detail(gname, id, slug=''): post: Post | None = db.session.execute(select(Post).join(Guild).where(Post.id == id, Guild.name == gname)).scalar() - if post is None or (post.is_removed and post.author != current_user): + if post is None or (post.author and post.author.has_blocked(current_user)) or (post.is_removed and post.author != current_user): abort(404) if post.slug and slug != post.slug: @@ -94,7 +100,7 @@ def guild_post_detail(gname, id, slug=''): if request.method == 'POST': single_post_post_hook(post) - return render_template('singlepost.html', p=post) + return render_template('singlepost.html', p=post, comments=comments_of(post)) diff --git a/freak/website/frontpage.py b/freak/website/frontpage.py index 5f3c780..0b103fb 100644 --- a/freak/website/frontpage.py +++ b/freak/website/frontpage.py @@ -11,7 +11,7 @@ bp = Blueprint('frontpage', __name__) @bp.route('/') def homepage(): - top_communities = [(x[0], x[1], 0) for x in + top_communities = [(x[0], x[1], x[2]) for x in db.session.execute(top_guilds_query().limit(10)).fetchall()] if current_user and current_user.is_authenticated: diff --git a/pyproject.toml b/pyproject.toml index ef60d03..2eb239b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,7 +18,7 @@ dependencies = [ "PsycoPG2-binary", "libsass", "setuptools>=78.1.0", - "sakuragasaki46-suou>=0.3.3" + "sakuragasaki46-suou>=0.3.4" ] requires-python = ">=3.10" classifiers = [ From e47103d0ee349cd2f3734c01bc075b49af4262a3 Mon Sep 17 00:00:00 2001 From: Yusur Princeps Date: Sun, 6 Jul 2025 22:58:33 +0200 Subject: [PATCH 19/73] bump libsuou requirement to 0.3.5 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 2eb239b..ce9489b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,7 +18,7 @@ dependencies = [ "PsycoPG2-binary", "libsass", "setuptools>=78.1.0", - "sakuragasaki46-suou>=0.3.4" + "sakuragasaki46-suou>=0.3.5" ] requires-python = ">=3.10" classifiers = [ From 0311586a1b150ff3ac0d873563732e6bba983304 Mon Sep 17 00:00:00 2001 From: Yusur Princeps Date: Mon, 7 Jul 2025 13:40:15 +0200 Subject: [PATCH 20/73] implement delete post --- CHANGELOG.md | 1 + alembic/versions/6d418df3c72f_.py | 34 +++++++++++++++++++++++++++++++ freak/models.py | 3 +-- freak/templates/singledelete.html | 27 ++++++++++++++++++++++++ freak/website/__init__.py | 3 +++ freak/website/delete.py | 31 ++++++++++++++++++++++++++++ freak/website/edit.py | 2 +- 7 files changed, 98 insertions(+), 3 deletions(-) create mode 100644 alembic/versions/6d418df3c72f_.py create mode 100644 freak/templates/singledelete.html create mode 100644 freak/website/delete.py diff --git a/CHANGELOG.md b/CHANGELOG.md index e5bc9eb..10f4493 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ - Added dependency to [SUOU](https://github.com/sakuragasaki46/suou) library - Added user blocks - Added user strikes: a strike logs the content of a removed message for future use +- Posts may now be deleted by author. If it has comments, comments are not spared - Implemented guild subscriptions + Blocking a user prevents them from seeing your comments, posts (standalone or in feed) and profile - Added ✨color themes✨ diff --git a/alembic/versions/6d418df3c72f_.py b/alembic/versions/6d418df3c72f_.py new file mode 100644 index 0000000..68c2f56 --- /dev/null +++ b/alembic/versions/6d418df3c72f_.py @@ -0,0 +1,34 @@ +"""empty message + +Revision ID: 6d418df3c72f +Revises: 90c7d0098efe +Create Date: 2025-07-07 13:37:51.667620 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '6d418df3c72f' +down_revision: Union[str, None] = '90c7d0098efe' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint('comment_parent_post_id_fkey', 'freak_comment', type_='foreignkey') + op.create_foreign_key('comment_parent_post_id', 'freak_comment', 'freak_post', ['parent_post_id'], ['id'], ondelete='cascade') + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint('comment_parent_post_id', 'freak_comment', type_='foreignkey') + op.create_foreign_key('comment_parent_post_id_fkey', 'freak_comment', 'freak_post', ['parent_post_id'], ['id']) + # ### end Alembic commands ### diff --git a/freak/models.py b/freak/models.py index 009bff7..a478595 100644 --- a/freak/models.py +++ b/freak/models.py @@ -245,7 +245,6 @@ class User(Base): target_id = target qq= ~select(UserBlock).where(UserBlock.c.actor_id == actor_id, UserBlock.c.target_id == target_id).exists() - print(qq) return qq def recompute_karma(self): @@ -437,7 +436,7 @@ class Comment(Base): id = snowflake_column() author_id = Column(BigInteger, ForeignKey('freak_user.id', name='comment_author_id'), nullable=True) - parent_post_id = Column(BigInteger, ForeignKey('freak_post.id', name='comment_parent_post_id'), nullable=False) + parent_post_id = Column(BigInteger, ForeignKey('freak_post.id', name='comment_parent_post_id', ondelete='cascade'), nullable=False) parent_comment_id = Column(BigInteger, ForeignKey('freak_comment.id', name='comment_parent_comment_id'), nullable=True) text_content = Column(String(16384), nullable=False) created_at = Column(DateTime, server_default=func.current_timestamp(), index=True) diff --git a/freak/templates/singledelete.html b/freak/templates/singledelete.html new file mode 100644 index 0000000..c60fb68 --- /dev/null +++ b/freak/templates/singledelete.html @@ -0,0 +1,27 @@ +{% extends "base.html" %} +{% from "macros/title.html" import title_tag with context %} +{% from "macros/icon.html" import icon, callout with context %} + +{% block title %}{{ title_tag('Confirm deletion: ' + p.title, False) }}{% endblock %} + +{% block heading %} +

      Confirm deletion: {{ p.title }}

      +{% endblock %} + +{% block content %} +
      +
      + +
      +

      You are about to delete permanently your post on {{ p.topic_or_user().handle() }}.

      + {% call callout('spoiler', 'error') %}This action cannot be undone.{% endcall %} + {% if (p.comments | count) %} + {% call callout('spoiler') %}Your post has {{ (p.comments | count) }} comments. Your post will be deleted along with ALL the comments.{% endcall %} + {% endif %} +
      +
      + +
      +
      +
      +{% endblock %} \ No newline at end of file diff --git a/freak/website/__init__.py b/freak/website/__init__.py index 5d19801..cc1aed7 100644 --- a/freak/website/__init__.py +++ b/freak/website/__init__.py @@ -17,6 +17,9 @@ blueprints.append(bp) from .edit import bp blueprints.append(bp) +from .delete import bp +blueprints.append(bp) + from .about import bp blueprints.append(bp) diff --git a/freak/website/delete.py b/freak/website/delete.py new file mode 100644 index 0000000..afbf5fa --- /dev/null +++ b/freak/website/delete.py @@ -0,0 +1,31 @@ + + +from flask import Blueprint, abort, flash, redirect, render_template, request +from flask_login import current_user, login_required +from sqlalchemy import delete, select + +from ..models import Post, db + + +bp = Blueprint('delete', __name__) + + +@bp.route('/delete/post/', methods=['GET', 'POST']) +@login_required +def delete_post(id: int): + p = db.session.execute(select(Post).where(Post.id == id, Post.author == current_user)).scalar() + + if p is None: + abort(404) + if p.author != current_user: + abort(403) + + pt = p.topic_or_user() + + if request.method == 'POST': + db.session.execute(delete(Post).where(Post.id == id, Post.author == current_user)) + db.session.commit() + flash('Your post has been deleted') + return redirect(pt.url()), 303 + + return render_template('singledelete.html', p=p) \ No newline at end of file diff --git a/freak/website/edit.py b/freak/website/edit.py index cfdf0be..3547b9f 100644 --- a/freak/website/edit.py +++ b/freak/website/edit.py @@ -14,7 +14,7 @@ bp = Blueprint('edit', __name__) @bp.route('/edit/post/', methods=['GET', 'POST']) @login_required def edit_post(id): - p: Post | None = db.session.execute(select(Post).where(Post.id == id)).scalar() + p: Post | None = db.session.execute(select(Post).where(Post.id == id, Post.author == current_user)).scalar() if p is None: abort(404) From 299c29869c7d2c1b5a7ee4e542e13a697bff5904 Mon Sep 17 00:00:00 2001 From: Yusur Princeps Date: Mon, 7 Jul 2025 13:46:26 +0200 Subject: [PATCH 21/73] fix migration artifacts --- alembic/versions/6d418df3c72f_.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/alembic/versions/6d418df3c72f_.py b/alembic/versions/6d418df3c72f_.py index 68c2f56..ccb6063 100644 --- a/alembic/versions/6d418df3c72f_.py +++ b/alembic/versions/6d418df3c72f_.py @@ -21,7 +21,7 @@ depends_on: Union[str, Sequence[str], None] = None def upgrade() -> None: """Upgrade schema.""" # ### commands auto generated by Alembic - please adjust! ### - op.drop_constraint('comment_parent_post_id_fkey', 'freak_comment', type_='foreignkey') + op.drop_constraint('comment_parent_post_id', 'freak_comment', type_='foreignkey') op.create_foreign_key('comment_parent_post_id', 'freak_comment', 'freak_post', ['parent_post_id'], ['id'], ondelete='cascade') # ### end Alembic commands ### @@ -30,5 +30,5 @@ def downgrade() -> None: """Downgrade schema.""" # ### commands auto generated by Alembic - please adjust! ### op.drop_constraint('comment_parent_post_id', 'freak_comment', type_='foreignkey') - op.create_foreign_key('comment_parent_post_id_fkey', 'freak_comment', 'freak_post', ['parent_post_id'], ['id']) + op.create_foreign_key('comment_parent_post_id', 'freak_comment', 'freak_post', ['parent_post_id'], ['id']) # ### end Alembic commands ### From 7d8b518c85100edf18c255b071f2455c0a7dcbe4 Mon Sep 17 00:00:00 2001 From: Yusur Princeps Date: Mon, 7 Jul 2025 14:02:45 +0200 Subject: [PATCH 22/73] add user list to admin panel --- freak/__init__.py | 2 +- freak/templates/admin/admin_home.html | 3 +++ freak/templates/admin/admin_users.html | 29 ++++++++++++++++++++++++++ freak/website/admin.py | 8 +++++++ 4 files changed, 41 insertions(+), 1 deletion(-) create mode 100644 freak/templates/admin/admin_users.html diff --git a/freak/__init__.py b/freak/__init__.py index 44416e0..3e6b177 100644 --- a/freak/__init__.py +++ b/freak/__init__.py @@ -22,7 +22,7 @@ from suou.configparse import ConfigOptions, ConfigValue from freak.colors import color_themes, theme_classes -__version__ = '0.4.0-dev24' +__version__ = '0.4.0-dev27' APP_BASE_DIR = os.path.dirname(os.path.dirname(__file__)) diff --git a/freak/templates/admin/admin_home.html b/freak/templates/admin/admin_home.html index bdaa7ae..6634f0b 100644 --- a/freak/templates/admin/admin_home.html +++ b/freak/templates/admin/admin_home.html @@ -8,5 +8,8 @@
    • Strikes

    • +
    • +

      Users

      +
    {% endblock %} \ No newline at end of file diff --git a/freak/templates/admin/admin_users.html b/freak/templates/admin/admin_users.html new file mode 100644 index 0000000..3ec789d --- /dev/null +++ b/freak/templates/admin/admin_users.html @@ -0,0 +1,29 @@ +{% extends "admin/admin_base.html" %} +{% from "macros/feed.html" import stop_scrolling, no_more_scrolling with context %} + +{% block content %} +
      + {% for u in user_list %} +
    • +

      {{ u.handle() }} (#{{ u.id | to_b32l }}) + {%- if u.is_administrator %} + (Admin) + {% endif -%} + {% if u == current_user %} + (You) + {% endif -%} +

      +
        +
      • Age: {{ u.age() }} years old ({{ u.gdpr_birthday.strftime("%B %d, %Y") }})
      • +
      • Registered at: {{ u.joined_at }}
      • +
      • Registered from IP address: {{ u.joined_ip }}
      • +
      +
    • + {% endfor %} + {% if user_list.has_next %} + {{ stop_scrolling(user_list.page) }} + {% else %} + {{ no_more_scrolling(user_list.page) }} + {% endif %} +
    +{% endblock %} \ No newline at end of file diff --git a/freak/website/admin.py b/freak/website/admin.py index bc9851e..04b1806 100644 --- a/freak/website/admin.py +++ b/freak/website/admin.py @@ -146,3 +146,11 @@ def strikes(): strike_list = db.paginate(select(UserStrike).order_by(UserStrike.id.desc())) return render_template('admin/admin_strikes.html', strike_list=strike_list, report_reasons=REPORT_REASON_STRINGS) + + +@bp.route('/admin/users/') +@admin_required +def users(): + user_list = db.paginate(select(User).order_by(User.joined_at.desc())) + return render_template('admin/admin_users.html', + user_list=user_list) From 6935a6ae71ca65b790f9946cd2c351834a019831 Mon Sep 17 00:00:00 2001 From: Yusur Princeps Date: Mon, 7 Jul 2025 14:42:52 +0200 Subject: [PATCH 23/73] add account status to user list --- freak/filters.py | 6 ++++++ freak/templates/admin/admin_users.html | 3 ++- freak/website/admin.py | 17 ++++++++++++++++- 3 files changed, 24 insertions(+), 2 deletions(-) diff --git a/freak/filters.py b/freak/filters.py index df56edd..f085ef9 100644 --- a/freak/filters.py +++ b/freak/filters.py @@ -40,3 +40,9 @@ def append(text, l: list): l.append(text) return None +@app.template_filter() +def faint_paren(text: str): + if not '(' in text: + return text + t1, t2, t3 = text.partition('(') + return Markup('{0} {1}').format(t1, t2 + t3) \ No newline at end of file diff --git a/freak/templates/admin/admin_users.html b/freak/templates/admin/admin_users.html index 3ec789d..58991fc 100644 --- a/freak/templates/admin/admin_users.html +++ b/freak/templates/admin/admin_users.html @@ -15,8 +15,9 @@

    • Age: {{ u.age() }} years old ({{ u.gdpr_birthday.strftime("%B %d, %Y") }})
    • -
    • Registered at: {{ u.joined_at }}
    • +
    • Registered at: {{ u.joined_at.strftime("%B %d, %Y %H:%M %z") }}
    • Registered from IP address: {{ u.joined_ip }}
    • +
    • Status: {{ account_status_string(u) | faint_paren }}
    {% endfor %} diff --git a/freak/website/admin.py b/freak/website/admin.py index 04b1806..2cfe919 100644 --- a/freak/website/admin.py +++ b/freak/website/admin.py @@ -30,6 +30,21 @@ TARGET_TYPES = { Comment: REPORT_TARGET_COMMENT } +def account_status_string(u: User): + if u.is_active: + return 'Active' + elif u.banned_at: + s = 'Suspended' + if u.banned_until: + s += f' until {u.banned_until:%b %d, %Y %H:%M}' + if u.banned_reason in REPORT_REASON_STRINGS: + s += f' ({REPORT_REASON_STRINGS[u.banned_reason]})' + return s + elif u.is_disabled_by_user: + return 'Paused' + else: + return 'Inactive' + def remove_content(target, reason_code: int): if isinstance(target, Post): target.removed_at = datetime.datetime.now() @@ -153,4 +168,4 @@ def strikes(): def users(): user_list = db.paginate(select(User).order_by(User.joined_at.desc())) return render_template('admin/admin_users.html', - user_list=user_list) + user_list=user_list, account_status_string=account_status_string) From 66471558b91aa516fa0c0d2e287d070144ff3cc7 Mon Sep 17 00:00:00 2001 From: Yusur Princeps Date: Mon, 7 Jul 2025 14:56:24 +0200 Subject: [PATCH 24/73] apply ProxyFix --- freak/__init__.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/freak/__init__.py b/freak/__init__.py index 3e6b177..ba98759 100644 --- a/freak/__init__.py +++ b/freak/__init__.py @@ -17,6 +17,7 @@ from sqlalchemy.exc import SQLAlchemyError from suou import Snowflake, ssv_list from werkzeug.routing import BaseConverter from sassutils.wsgi import SassMiddleware +from werkzeug.middleware.proxy_fix import ProxyFix from suou.configparse import ConfigOptions, ConfigValue @@ -36,6 +37,7 @@ class AppConfig(ConfigOptions): domain_name = ConfigValue() private_assets = ConfigValue(cast=ssv_list) jquery_url = ConfigValue(default='https://ajax.googleapis.com/ajax/libs/jquery/3.7.1/jquery.min.js') + app_is_behind_proxy = ConfigValue(cast=bool, default=False) app_config = AppConfig() @@ -51,6 +53,12 @@ app.wsgi_app = SassMiddleware(app.wsgi_app, dict( freak=('static/sass', 'static/css', '/static/css', True) )) +# proxy fix +if app_config.app_is_behind_proxy: + app.wsgi_app = ProxyFix( + app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1 + ) + class SlugConverter(BaseConverter): regex = r'[a-z0-9]+(?:-[a-z0-9]+)*' From 793c0b6612d9dfcdf6207e64a3c3c50eb6b226e2 Mon Sep 17 00:00:00 2001 From: Yusur Princeps Date: Mon, 7 Jul 2025 16:47:52 +0200 Subject: [PATCH 25/73] mobile style improvements, block registration with date unset --- freak/static/sass/layout.sass | 6 ++-- freak/static/sass/mobile.sass | 55 +++++++++++++++++++++++++++++++++-- freak/templates/base.html | 15 ++++++++-- freak/website/accounts.py | 5 ++++ 4 files changed, 75 insertions(+), 6 deletions(-) diff --git a/freak/static/sass/layout.sass b/freak/static/sass/layout.sass index 9dbd8c9..c042424 100644 --- a/freak/static/sass/layout.sass +++ b/freak/static/sass/layout.sass @@ -1,7 +1,9 @@ @import "constants.sass" - +body + margin: 0 + .content-container display: flex flex-direction: row-reverse @@ -18,6 +20,7 @@ main min-height: 70vh + margin: 12px auto // __ header styles __ // @@ -28,7 +31,6 @@ header.header overflow: hidden height: 3em padding: .75em 1.5em - margin: -12px line-height: 1 h1 margin: 0 diff --git a/freak/static/sass/mobile.sass b/freak/static/sass/mobile.sass index 2ed1831..4d45ce0 100644 --- a/freak/static/sass/mobile.sass +++ b/freak/static/sass/mobile.sass @@ -10,7 +10,58 @@ grid-template-columns: 1fr 1fr .nomobile - display: none + display: none !important + + body + position: relative + + footer.mobile-nav + position: sticky + bottom: 0 + left: 0 + width: 100% + overflow: hidden + margin: 0 + padding: 0 + background-color: var(--background) + box-shadow: 0 0 6px var(--border) + z-index: 150 + + > ul + display: flex + list-style: none + margin: 0 + padding: 0 + flex-direction: row + align-items: stretch + justify-content: stretch + > li + flex: 1 + padding: .5em + margin: 0 + text-align: center + a + text-decoration: none + .icon + font-size: 2rem + + .content-nav + margin: 1em + width: unset + + header.header h1 + margin-top: 4px + margin-left: 6px + + .content-header + text-align: center + + .big-search-bar form + flex-direction: column + + [type="submit"] + width: unset + margin: 12px auto @media screen and (max-width: 960px) .header-username @@ -33,4 +84,4 @@ @media screen and (min-width: 801px) .mobileonly - display: none \ No newline at end of file + display: none !important \ No newline at end of file diff --git a/freak/templates/base.html b/freak/templates/base.html index 579cee4..7a97685 100644 --- a/freak/templates/base.html +++ b/freak/templates/base.html @@ -46,11 +46,11 @@ {% if g.no_user %} {% elif current_user.is_authenticated %} -
  • +
  • create - New post + New post
  • {{ icon('profile')}}profile @@ -99,6 +99,17 @@
  • GitHub
+ {% if current_user and current_user.is_authenticated %} + + {% endif %} + diff --git a/freak/templates/admin/admin_strikes.html b/freak/templates/admin/admin_strikes.html index cab08c2..28825cf 100644 --- a/freak/templates/admin/admin_strikes.html +++ b/freak/templates/admin/admin_strikes.html @@ -9,7 +9,6 @@
  • Reason: {{ report_reasons[strike.reason_code] }}
  • -
{% endfor %} {% if strike_list.has_next %} diff --git a/freak/templates/admin/admin_user_detail.html b/freak/templates/admin/admin_user_detail.html new file mode 100644 index 0000000..52b1cd3 --- /dev/null +++ b/freak/templates/admin/admin_user_detail.html @@ -0,0 +1,46 @@ +{% extends "admin/admin_base.html" %} +{% from "macros/icon.html" import callout with context %} + +{% block content %} +

User: {{ u.handle() }}

+ +
    +
  • Age: {{ u.age() }} years old ({{ u.gdpr_birthday.strftime("%B %d, %Y") }})
  • +
  • Registered at: {{ u.joined_at.strftime("%B %d, %Y %H:%M %z") }}
  • +
  • Registered from IP address: {{ u.joined_ip }}
  • +
  • Status: {{ account_status_string(u) }}
  • +
  • Karma: {{ u.karma }}
  • + {% if u.email %} +
  • E-mail: {{ u.email }}
  • + {% endif %} +
+ +{% if u.banned_at %} +{% call callout('spoiler', 'error') %} +{{ u.handle() }} is suspended +{% if u.banned_until %}until {{ u.banned_until.strftime("%B %d, %Y %H:%M %z") }}{% else %}indefinitely{% endif %}. +{% if u.banned_message %}
Ban message: “{{ u.banned_message }}”{% endif %} +{% endcall %} +{% endif %} + + +
+
+ +

Strikes

+ +{% if strikes %} +
    + {% for strike in strikes %} +
  • +

    #{{ strike.id | to_cb32 }}

    +
      +
    • Reason: {{ report_reasons[strike.reason_code] }}
    • +
    • {{ strike.text_content }}
    • + + {% endfor %} +
    +{% else %} +

    {{ u.handle() }} is all good!

    +{% endif %} +{% endblock %} \ No newline at end of file diff --git a/freak/templates/admin/admin_users.html b/freak/templates/admin/admin_users.html index 58991fc..31079c4 100644 --- a/freak/templates/admin/admin_users.html +++ b/freak/templates/admin/admin_users.html @@ -5,7 +5,7 @@
      {% for u in user_list %}
    • -

      {{ u.handle() }} (#{{ u.id | to_b32l }}) +

      {{ u.handle() }} (#{{ u.id | to_b32l }}) {%- if u.is_administrator %} (Admin) {% endif -%} @@ -17,7 +17,7 @@

    • Age: {{ u.age() }} years old ({{ u.gdpr_birthday.strftime("%B %d, %Y") }})
    • Registered at: {{ u.joined_at.strftime("%B %d, %Y %H:%M %z") }}
    • Registered from IP address: {{ u.joined_ip }}
    • -
    • Status: {{ account_status_string(u) | faint_paren }}
    • +
    • Status: {{ account_status_string(u) }}
  • {% endfor %} diff --git a/freak/website/admin.py b/freak/website/admin.py index 2cfe919..1a75ed1 100644 --- a/freak/website/admin.py +++ b/freak/website/admin.py @@ -6,6 +6,7 @@ from typing import Callable import warnings from flask import Blueprint, abort, redirect, render_template, request, url_for from flask_login import current_user +from markupsafe import Markup from sqlalchemy import insert, select, update from suou import additem, not_implemented @@ -36,7 +37,7 @@ def account_status_string(u: User): elif u.banned_at: s = 'Suspended' if u.banned_until: - s += f' until {u.banned_until:%b %d, %Y %H:%M}' + s += f' (until {u.banned_until:%b %d, %Y %H:%M})' if u.banned_reason in REPORT_REASON_STRINGS: s += f' ({REPORT_REASON_STRINGS[u.banned_reason]})' return s @@ -45,6 +46,19 @@ def account_status_string(u: User): else: return 'Inactive' +def colorized_account_status_string(u: User): + textc = account_status_string(u) + t1, t2, t3 = textc.partition('(') + if u.is_active: + base = '{0}' + elif u.banned_at: + base = '{0}' + else: + base = '{0}' + if t2: + base += ' {1}' + return Markup(base).format(t1, t2 + t3) + def remove_content(target, reason_code: int): if isinstance(target, Post): target.removed_at = datetime.datetime.now() @@ -168,4 +182,16 @@ def strikes(): def users(): user_list = db.paginate(select(User).order_by(User.joined_at.desc())) return render_template('admin/admin_users.html', - user_list=user_list, account_status_string=account_status_string) + user_list=user_list, account_status_string=colorized_account_status_string) + +@bp.route('/admin/users/', methods=['GET', 'POST']) +@admin_required +def user_detail(id: int): + u = db.session.execute(select(User).where(User.id == id)).scalar() + if u is None: + abort(404) + if request.method == 'POST': + abort(501) + strikes = db.session.execute(select(UserStrike).where(UserStrike.user_id == id).order_by(UserStrike.id.desc())).scalars() + return render_template('admin/admin_user_detail.html', u=u, + report_reasons=REPORT_REASON_STRINGS, account_status_string=colorized_account_status_string, strikes=strikes) From a88b12e84439b1a818d962d46abb4fb55d6a572d Mon Sep 17 00:00:00 2001 From: Yusur Princeps Date: Thu, 17 Jul 2025 22:07:12 +0200 Subject: [PATCH 32/73] make create guild threshold configurable, add savepoint --- CHANGELOG.md | 7 +++++-- freak/__init__.py | 1 + freak/models.py | 17 +++++++++++++++-- freak/templates/create.html | 9 +++++---- freak/website/create.py | 34 +++++++++++++++++++++++++++------- 5 files changed, 53 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ba5f63..3b89685 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,15 +6,18 @@ - Users can now block each other + Blocking a user prevents them from seeing your comments, posts (standalone or in feed) and profile - Added user strikes: a strike logs the content of a removed message for future use +- Added ✨**color themes**✨ - Posts may now be deleted by author. If it has comments, comments are not spared +- If a user for some reason can't post, their post is blocked and they can choose to post it onto another community. Previously it got posted to the user page. - Moderators (and admins) have now access to mod tools + Allowed operations: change display name, description, restriction status, and exile (guild-local ban) members + Site administrators and guild owners can add moderators - Administrators can claim ownership of abandoned guilds - Implemented guild subscriptions (not as in $$$, yes as in the follow button) -- Added ✨color themes✨ +- Minimum karma requirement for creating a guild is now configurable via env variable `FREAK_CREATE_GUILD_THRESHOLD` (previously hardcoded at 15) - Users can now set their display name, biography and color theme in `/settings` -- You can now add an impressum in .env, e.g. `IMPRESSUM='Acme Ltd.::1 Short Island::Old York, Divided States::Responsible: ::Donald Duck'` Lines are separated by two colons. Version before 0.4.0 CAN'T BE RUN in German-speaking countries as of 2025. +- Impressum can now be set in .env, e.g. `IMPRESSUM='Acme Ltd.::1 Short Island::Old York, Divided States::Responsible: ::Donald Duck'` Lines are separated by two colons. **Versions before 0.4.0 CAN'T BE RUN in German-speaking countries** as of 2025. +- Several aesthetic improvements ## 0.3.3 diff --git a/freak/__init__.py b/freak/__init__.py index 25f7452..e84954c 100644 --- a/freak/__init__.py +++ b/freak/__init__.py @@ -40,6 +40,7 @@ class AppConfig(ConfigOptions): jquery_url = ConfigValue(default='https://ajax.googleapis.com/ajax/libs/jquery/3.7.1/jquery.min.js') app_is_behind_proxy = ConfigValue(cast=bool, default=False) impressum = ConfigValue(cast=twocolon_list, default='') + create_guild_threshold = ConfigValue(cast=int, default=15, prefix='freak_') app_config = AppConfig() diff --git a/freak/models.py b/freak/models.py index f2f4657..ab0e6b3 100644 --- a/freak/models.py +++ b/freak/models.py @@ -220,8 +220,8 @@ class User(Base): db.session.commit() def can_create_guild(self): - ## TODO make guild creation requirements configurable - return self.karma > 15 or self.is_administrator + ## TODO make guild creation requirements fully configurable + return self.karma > app_config.create_guild_threshold or self.is_administrator can_create_community = deprecated('use .can_create_guild()')(can_create_guild) @@ -356,6 +356,19 @@ class Guild(Base): u = db.session.execute(select(Member).where(Member.user_id == other.id, Member.guild_id == self.id)).scalar() return u.is_banned if u else False + def allows_posting(self, other: User) -> bool: + if self.owner is None: + return False + if other.is_disabled: + return False + mem: Member | None = db.session.execute(select(Member).where(Member.user_id == other.id, Member.guild_id == self.id)).scalar() if other else None + if mem and mem.is_banned: + return False + if self.is_restricted: + return mem and mem.is_approved + return True + + def moderators(self): if self.owner: yield ModeratorInfo(self.owner, True) diff --git a/freak/templates/create.html b/freak/templates/create.html index db3f011..5e5095c 100644 --- a/freak/templates/create.html +++ b/freak/templates/create.html @@ -14,15 +14,16 @@

    Posting as {{ current_user.handle() }}

    -

    Post to:

    +

    Post to:

    - +
    +
    - +
    {#
    Add a file...#} -
    {{ privacy_select() }}
    +
    {{ privacy_select(sv_privacy) }}
    diff --git a/freak/website/create.py b/freak/website/create.py index 59b50cc..ecbd05f 100644 --- a/freak/website/create.py +++ b/freak/website/create.py @@ -7,23 +7,43 @@ from flask_login import current_user, login_required from sqlalchemy import insert, select from ..models import User, db, Guild, Post +current_user: User + bp = Blueprint('create', __name__) +def create_savepoint( + target = '', title = '', content = '', + privacy = 0 +): + return render_template('create.html', + sv_target = target, + sv_title = title, + sv_content = content, + sv_privacy = privacy + ) + @bp.route('/create/', methods=['GET', 'POST']) @login_required def create(): user: User = current_user if request.method == 'POST' and 'title' in request.form: gname = request.form['to'] - if gname: - guild: Guild | None = db.session.execute(select(Guild).where(Guild.name == gname)).scalar() - if guild is None: - flash(f'Guild +{gname} not found or inaccessible, posting to your user page instead') - else: - guild = None title = request.form['title'] text = request.form['text'] privacy = int(request.form.get('privacy', '0')) + if gname: + guild: Guild | None = db.session.execute(select(Guild).where(Guild.name == gname)).scalar() + if guild is None: + flash(f'Guild +{gname} not found or inaccessible') + return create_savepoint('', title, text, privacy) + if guild.has_exiled(user): + flash(f'You are banned from +{gname}') + return create_savepoint('', title, text, privacy) + if not guild.allows_posting(user): + flash(f'You can\'t post on +{gname}') + return create_savepoint('', title, text, privacy) + else: + guild = None try: new_post: Post = db.session.execute(insert(Post).values( author_id = user.id, @@ -40,7 +60,7 @@ def create(): except Exception as e: sys.excepthook(*sys.exc_info()) flash('Unable to publish!') - return render_template('create.html') + return create_savepoint(target=request.args.get('on','')) @bp.route('/createguild/', methods=['GET', 'POST']) From e7912ad88ca2108462eeea79a872048cc7dfab40 Mon Sep 17 00:00:00 2001 From: Yusur Princeps Date: Fri, 18 Jul 2025 00:10:02 +0200 Subject: [PATCH 33/73] implement post and comment restrictions, changes to views --- CHANGELOG.md | 1 + freak/ajax.py | 4 ++-- freak/models.py | 13 ++++++++----- freak/static/js/lib.js | 15 ++++++++++++--- freak/templates/base.html | 2 +- freak/templates/createguild.html | 2 +- freak/templates/macros/create.html | 12 ++++++++++-- freak/templates/singlepost.html | 2 +- freak/website/detail.py | 16 +++++++++++++++- freak/website/frontpage.py | 3 ++- 10 files changed, 53 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b89685..40a4377 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ + Allowed operations: change display name, description, restriction status, and exile (guild-local ban) members + Site administrators and guild owners can add moderators - Administrators can claim ownership of abandoned guilds +- Guilds can have restricted posting/commenting now. Unmoderated guilds always have. - Implemented guild subscriptions (not as in $$$, yes as in the follow button) - Minimum karma requirement for creating a guild is now configurable via env variable `FREAK_CREATE_GUILD_THRESHOLD` (previously hardcoded at 15) - Users can now set their display name, biography and color theme in `/settings` diff --git a/freak/ajax.py b/freak/ajax.py index 5fe2d04..00107ed 100644 --- a/freak/ajax.py +++ b/freak/ajax.py @@ -33,9 +33,9 @@ def username_availability(username: str): 'is_available': is_available } -@bp.route('/guild_name_availability/') +@bp.route('/guild_name_availability/') def guild_name_availability(name: str): - is_valid = re.fullmatch('[a-z0-9_-]+', name) is not None + is_valid = username_is_legal(name) if is_valid: gd = db.session.execute(select(Guild).where(Guild.name == name)).scalar() diff --git a/freak/models.py b/freak/models.py index ab0e6b3..6e68213 100644 --- a/freak/models.py +++ b/freak/models.py @@ -42,9 +42,10 @@ post_report_reasons = [ ReportReason(180, 'impersonation', 'Impersonation'), ReportReason(141, 'doxing', 'Diffusion of PII (personally identifiable information)'), ## less urgent - ReportReason(140, 'bullying', 'Harassment, bullying, or suicide incitement'), ReportReason(112, 'lgbt_hate', 'Hate speech against LGBTQ+ or women'), + ReportReason(140, 'bullying', 'Harassment, bullying, or suicide incitement'), ReportReason(150, 'security_exploit', 'Dangerous security exploit or violation'), + ReportReason(160, 'spam', 'Unsolicited advertising'), ReportReason(190, 'false_information', 'False or deceiving information'), ReportReason(123, 'copyviol', 'This is my creation and someone else is using it without my permission/license'), ## minor (unironically) @@ -168,7 +169,7 @@ class User(Base): ## XXX posts and comments relationships are temporarily disabled because they make ## SQLAlchemy fail initialization of models — bricking the app. ## Posts are queried manually anyway - #posts = relationship("Post", back_populates='author', ) + #posts = relationship("Post", primaryjoin=lambda: #back_populates='author', pr) upvoted_posts = relationship("Post", secondary=PostUpvote, back_populates='upvoters') #comments = relationship("Comment", back_populates='author') @@ -364,8 +365,10 @@ class Guild(Base): mem: Member | None = db.session.execute(select(Member).where(Member.user_id == other.id, Member.guild_id == self.id)).scalar() if other else None if mem and mem.is_banned: return False + if other.moderates(self): + return True if self.is_restricted: - return mem and mem.is_approved + return (mem and mem.is_approved) return True @@ -447,7 +450,7 @@ class Post(Base): title = Column(String(256), nullable=False) post_type = Column(SmallInteger, server_default=text('0')) author_id = Column(BigInteger, ForeignKey('freak_user.id', name='post_author_id'), nullable=True) - topic_id = Column(BigInteger, ForeignKey('freak_topic.id', name='post_topic_id'), nullable=True) + topic_id = Column('topic_id', BigInteger, ForeignKey('freak_topic.id', name='post_topic_id'), nullable=True) created_at = Column(DateTime, server_default=func.current_timestamp()) created_ip = Column(String(64), default=get_remote_addr, nullable=False) updated_at = Column(DateTime, nullable=True) @@ -465,7 +468,7 @@ class Post(Base): # utilities author: Relationship[User] = relationship("User", lazy='selectin', foreign_keys=[author_id])#, back_populates="posts") - guild = relationship("Guild", back_populates="posts", lazy='selectin') + guild: Relationship[Guild] = relationship("Guild", back_populates="posts", lazy='selectin') comments = relationship("Comment", back_populates="parent_post") upvoters = relationship("User", secondary=PostUpvote, back_populates='upvoted_posts') diff --git a/freak/static/js/lib.js b/freak/static/js/lib.js index 0e6f824..450d97c 100644 --- a/freak/static/js/lib.js +++ b/freak/static/js/lib.js @@ -27,21 +27,30 @@ usernameInputMessage.className = 'username-input-message error'; return; } + if (value.length >= 100) { + usernameInputMessage.innerHTML = 'Your username must be shorter.'; + usernameInputMessage.className = 'username-input-message error'; + return; + } if(/^[01]/.test(value)) { usernameInputMessage.innerHTML = 'Your username cannot start with 0 or 1.'; usernameInputMessage.className = 'username-input-message error'; return; } usernameInputMessage.innerHTML = 'Checking username...'; - usernameInputMessage.className = 'username-input-message checking'; + usernameInputMessage.className = 'username-input-message checking faint'; requestUsernameAvailability(value, endpoint).then((resp) => { if (['ok', void 0].indexOf(resp.status) < 0){ usernameInputMessage.innerHTML = 'Sorry, there was an unknown error.'; usernameInputMessage.className = 'username-input-message error'; return; } - if (resp.is_available){ - usernameInputMessage.innerHTML = "The username @" + value + " is available!"; + if (!resp.is_legal) { + usernameInputMessage.innerHTML = "You can't use this username."; + usernameInputMessage.className = 'username-input-message error'; + return; + } else if (resp.is_available){ + usernameInputMessage.innerHTML = `The username @${value} is available!`; usernameInputMessage.className = 'username-input-message success'; return; } else { diff --git a/freak/templates/base.html b/freak/templates/base.html index 9009c1f..4833a27 100644 --- a/freak/templates/base.html +++ b/freak/templates/base.html @@ -47,7 +47,7 @@ {% elif current_user.is_authenticated %}
  • - + {{ icon('add') }} New post diff --git a/freak/templates/createguild.html b/freak/templates/createguild.html index b004482..8c03c7c 100644 --- a/freak/templates/createguild.html +++ b/freak/templates/createguild.html @@ -16,7 +16,7 @@
    -

    URL of the guild: +

    +

    URL of the guild: +

    Must be alphanumeric and unique. May not be changed later: choose wisely!

    diff --git a/freak/templates/macros/create.html b/freak/templates/macros/create.html index 6a3a609..360568f 100644 --- a/freak/templates/macros/create.html +++ b/freak/templates/macros/create.html @@ -22,9 +22,16 @@ disabled=""
{% endmacro %} -{% macro comment_area(url) %} +{% macro comment_area(p) %} {% if current_user.is_authenticated %} - +{% if current_user.is_disabled %} +
Your account is suspended
+{% elif current_guild and not current_guild.allows_posting(current_user) %} +
This community allows only its members to post and comment
+{% elif p.is_locked %} +
Comments are closed
+{% else %} +
@@ -35,6 +42,7 @@ disabled=""
+{% endif %} {% else %}
Log in to leave a comment
{% endif %} diff --git a/freak/templates/singlepost.html b/freak/templates/singlepost.html index a4f5955..d6947ef 100644 --- a/freak/templates/singlepost.html +++ b/freak/templates/singlepost.html @@ -66,7 +66,7 @@
- {{ comment_area(p.url()) }} + {{ comment_area(p) }}
    {% for comment in comments %} diff --git a/freak/website/detail.py b/freak/website/detail.py index 3de15da..e3d11d1 100644 --- a/freak/website/detail.py +++ b/freak/website/detail.py @@ -40,6 +40,20 @@ def user_profile_s(username): def single_post_post_hook(p: Post): + if p.guild is not None: + gu = p.guild + if gu.has_exiled(current_user): + flash(f'You have been banned from {gu.handle()}') + return + + if not gu.allows_posting(current_user): + flash(f'You can\'t post in {gu.handle()}') + return + + if p.is_locked: + flash(f'You can\'t comment on locked posts') + return + if 'reply_to' in request.form: reply_to_id = request.form['reply_to'] text = request.form['text'] @@ -100,7 +114,7 @@ def guild_post_detail(gname, id, slug=''): if request.method == 'POST': single_post_post_hook(post) - return render_template('singlepost.html', p=post, comments=comments_of(post)) + return render_template('singlepost.html', p=post, comments=comments_of(post), current_guild = post.guild) diff --git a/freak/website/frontpage.py b/freak/website/frontpage.py index 0b103fb..acf968c 100644 --- a/freak/website/frontpage.py +++ b/freak/website/frontpage.py @@ -41,7 +41,8 @@ def guild_feed(name): posts = db.paginate(topic_timeline(name)) return render_template( - 'feed.html', feed_type='guild', feed_title=f'{guild.display_name} (+{guild.name})', l=posts, guild=guild) + 'feed.html', feed_type='guild', feed_title=f'{guild.display_name} (+{guild.name})', l=posts, guild=guild, + current_guild=guild) @bp.route('/r//') def guild_feed_r(name): From 99b816562c644e5c27ff6dec5a2b052600033088 Mon Sep 17 00:00:00 2001 From: Yusur Princeps Date: Sun, 20 Jul 2025 22:12:49 +0200 Subject: [PATCH 34/73] fix account suspension logic, aest' fixes, add ability to suspend user from admin panel, 0.4.0 feature freeze --- CHANGELOG.md | 3 ++- freak/models.py | 10 ++++++-- freak/static/sass/layout.sass | 6 +++++ freak/templates/403.html | 2 +- freak/templates/404.html | 2 +- freak/templates/405.html | 2 +- freak/templates/admin/admin_user_detail.html | 16 +++++++++++++ freak/website/admin.py | 25 ++++++++++++++++++-- 8 files changed, 58 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 40a4377..be61e29 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,8 +12,9 @@ - Moderators (and admins) have now access to mod tools + Allowed operations: change display name, description, restriction status, and exile (guild-local ban) members + Site administrators and guild owners can add moderators -- Administrators can claim ownership of abandoned guilds - Guilds can have restricted posting/commenting now. Unmoderated guilds always have. +- Administrators can claim ownership of abandoned guilds +- Admins can now suspend users from admin panel - Implemented guild subscriptions (not as in $$$, yes as in the follow button) - Minimum karma requirement for creating a guild is now configurable via env variable `FREAK_CREATE_GUILD_THRESHOLD` (previously hardcoded at 15) - Users can now set their display name, biography and color theme in `/settings` diff --git a/freak/models.py b/freak/models.py index 6e68213..25f4485 100644 --- a/freak/models.py +++ b/freak/models.py @@ -54,7 +54,7 @@ post_report_reasons = [ REPORT_REASON_STRINGS = { **{x.num_code: x.description for x in post_report_reasons}, **{x.code: x.description for x in post_report_reasons} } -REPORT_REASONS = {x.code: x.num_code for x in post_report_reasons} +REPORT_REASONS: dict[str, int] = {x.code: x.num_code for x in post_report_reasons} REPORT_TARGET_POST = 1 REPORT_TARGET_COMMENT = 2 @@ -175,7 +175,13 @@ class User(Base): @property def is_disabled(self): - return (self.banned_at is not None and (self.banned_until is None or self.banned_until <= datetime.datetime.now())) or self.is_disabled_by_user + now = datetime.datetime.now() + return ( + # suspended + (self.banned_at is not None and (self.banned_until is None or self.banned_until >= now)) or + # self-disabled + self.is_disabled_by_user + ) @property def is_active(self): diff --git a/freak/static/sass/layout.sass b/freak/static/sass/layout.sass index 9325fb7..ae79fd1 100644 --- a/freak/static/sass/layout.sass +++ b/freak/static/sass/layout.sass @@ -310,6 +310,12 @@ button, [type="submit"], [type="reset"], [type="button"] &[disabled] opacity: .5 cursor: not-allowed + border: var(--border) + color: var(--border) + + &.primary[disabled] + color: var(--background) + background-color: var(--border) &:first-child margin-inline-start: 0 diff --git a/freak/templates/403.html b/freak/templates/403.html index f13fb50..ee4f511 100644 --- a/freak/templates/403.html +++ b/freak/templates/403.html @@ -2,7 +2,7 @@ {% from "macros/title.html" import title_tag with context %} {% block title %} - {{ title_tag('X _ X') }} + {{ title_tag('X _ X') }} {% endblock %} {% block body %} diff --git a/freak/templates/404.html b/freak/templates/404.html index e3b427a..4a9f92b 100644 --- a/freak/templates/404.html +++ b/freak/templates/404.html @@ -2,7 +2,7 @@ {% from "macros/title.html" import title_tag with context %} {% block title %} - {{ title_tag('O _ O') }} + {{ title_tag('O _ O') }} {% endblock %} {% block body %} diff --git a/freak/templates/405.html b/freak/templates/405.html index 0151dcc..02c926b 100644 --- a/freak/templates/405.html +++ b/freak/templates/405.html @@ -2,7 +2,7 @@ {% from "macros/title.html" import title_tag with context %} {% block title %} - {{ title_tag('O _ O') }} + {{ title_tag('O _ O') }} {% endblock %} {% block body %} diff --git a/freak/templates/admin/admin_user_detail.html b/freak/templates/admin/admin_user_detail.html index 52b1cd3..36f8cb3 100644 --- a/freak/templates/admin/admin_user_detail.html +++ b/freak/templates/admin/admin_user_detail.html @@ -24,7 +24,23 @@ {% endif %} +

    Quick Actions

    + + +
    + {% if u.banned_at %} + + {% else %} + + + {% endif %}

    Strikes

    diff --git a/freak/website/admin.py b/freak/website/admin.py index 1a75ed1..682f749 100644 --- a/freak/website/admin.py +++ b/freak/website/admin.py @@ -10,10 +10,12 @@ from markupsafe import Markup from sqlalchemy import insert, select, update from suou import additem, not_implemented -from ..models import REPORT_REASON_STRINGS, REPORT_TARGET_COMMENT, REPORT_TARGET_POST, REPORT_UPDATE_COMPLETE, REPORT_UPDATE_ON_HOLD, REPORT_UPDATE_REJECTED, Comment, Post, PostReport, User, UserStrike, db +from ..models import REPORT_REASON_STRINGS, REPORT_REASONS, REPORT_TARGET_COMMENT, REPORT_TARGET_POST, REPORT_UPDATE_COMPLETE, REPORT_UPDATE_ON_HOLD, REPORT_UPDATE_REJECTED, Comment, Post, PostReport, User, UserStrike, db bp = Blueprint('admin', __name__) +current_user: User + ## TODO make admin interface def admin_required(func: Callable): @@ -191,7 +193,26 @@ def user_detail(id: int): if u is None: abort(404) if request.method == 'POST': - abort(501) + action = request.form['do'] + if action == 'suspend': + u.banned_at = datetime.datetime.now() + u.banned_by_id = current_user.id + u.banned_reason = REPORT_REASONS.get(request.form.get('reason'), 0) + db.session.commit() + elif action == 'unsuspend': + u.banned_at = None + u.banned_by_id = None + u.banned_until = None + u.banned_reason = None + db.session.commit() + elif action == 'to_3d': + u.banned_at = datetime.datetime.now() + u.banned_until = datetime.datetime.now() + datetime.timedelta(days=3) + u.banned_by_id = current_user.id + u.banned_reason = REPORT_REASONS.get(request.form.get('reason'), 0) + db.session.commit() + else: + abort(400) strikes = db.session.execute(select(UserStrike).where(UserStrike.user_id == id).order_by(UserStrike.id.desc())).scalars() return render_template('admin/admin_user_detail.html', u=u, report_reasons=REPORT_REASON_STRINGS, account_status_string=colorized_account_status_string, strikes=strikes) From 48f05820964195ab206279c479ae8443ec164908 Mon Sep 17 00:00:00 2001 From: Yusur Princeps Date: Sun, 20 Jul 2025 22:27:34 +0200 Subject: [PATCH 35/73] fix JS bug, outlaw something more --- freak/models.py | 23 ++++++++++++----------- freak/static/js/lib.js | 2 +- 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/freak/models.py b/freak/models.py index 25f4485..904c969 100644 --- a/freak/models.py +++ b/freak/models.py @@ -66,21 +66,22 @@ REPORT_UPDATE_ON_HOLD = 3 USERNAME_RE = r'[a-z2-9_-][a-z0-9_-]+' -ILLEGAL_USERNAMES = ( +ILLEGAL_USERNAMES = tuple(( ## masspings and administrative claims - 'me', 'everyone', 'here', 'room', 'all', 'any', 'founder', 'owner', - 'admin', 'administrator', 'mod', 'modteam', 'moderator', 'sysop', 'server', 'app' + 'me everyone here room all any server app dev devel develop nil none ' + 'founder owner admin administrator mod modteam moderator sysop some ' ## fictitious users and automations - 'nobody', 'deleted', 'suspended', 'default', 'bot', 'developer', 'undefined', 'null', - 'ai', 'automod', 'automoderator', 'assistant', 'privacy', 'anonymous', 'removed' + 'nobody deleted suspended default bot developer undefined null ' + 'ai automod automoderator assistant privacy anonymous removed assistance ' ## law enforcement corps and slurs because yes - 'pedo', 'rape', 'rapist', 'nigger', 'retard', 'ncmec', 'police', 'cops', '911', 'childsafety', - 'report', 'dmca', 'login', 'logout', 'security', 'order66', 'gestapo', 'ss', 'hitler', - 'pedophile', 'lolicon', 'giphy', 'tenor', 'csam', 'cp', 'pedobear', 'lolita', - 'loli', 'kkk', 'pnf', 'adl', 'cop', 'tranny', 'google', 'trustandsafety', 'safety', 'ice', + 'pedo rape rapist nigger retard ncmec police cops 911 childsafety ' + 'report dmca login logout security order66 gestapo ss hitler heilhitler kgb ' + 'pedophile lolicon giphy tenor csam cp pedobear lolita lolice thanos ' + 'loli kkk pnf adl cop tranny google trustandsafety safety ice fbi nsa it ' ## VVVVIP - 'potus', 'realdonaldtrump', 'elonmusk', 'teddysphotos', 'mrbeast', 'jkrowling', 'pewdiepie' -) + 'potus realdonaldtrump elonmusk teddysphotos mrbeast jkrowling pewdiepie ' + 'elizabethii king queen pontifex hogwarts lumos alohomora ' +).split()) def username_is_legal(username: str) -> bool: if len(username) < 2 or len(username) > 100: diff --git a/freak/static/js/lib.js b/freak/static/js/lib.js index 450d97c..a9d7557 100644 --- a/freak/static/js/lib.js +++ b/freak/static/js/lib.js @@ -45,7 +45,7 @@ usernameInputMessage.className = 'username-input-message error'; return; } - if (!resp.is_legal) { + if (resp.is_valid === false) { usernameInputMessage.innerHTML = "You can't use this username."; usernameInputMessage.className = 'username-input-message error'; return; From a1dffc6a371869b0504f038badbe79f2bb0c1b9f Mon Sep 17 00:00:00 2001 From: Yusur Princeps Date: Mon, 21 Jul 2025 00:00:13 +0200 Subject: [PATCH 36/73] don't suggest posting in restricted communities ~ --- freak/models.py | 2 +- freak/templates/base.html | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/freak/models.py b/freak/models.py index 904c969..caf4e72 100644 --- a/freak/models.py +++ b/freak/models.py @@ -80,7 +80,7 @@ ILLEGAL_USERNAMES = tuple(( 'loli kkk pnf adl cop tranny google trustandsafety safety ice fbi nsa it ' ## VVVVIP 'potus realdonaldtrump elonmusk teddysphotos mrbeast jkrowling pewdiepie ' - 'elizabethii king queen pontifex hogwarts lumos alohomora ' + 'elizabethii king queen pontifex hogwarts lumos alohomora isis daesh ' ).split()) def username_is_legal(username: str) -> bool: diff --git a/freak/templates/base.html b/freak/templates/base.html index 4833a27..a4eb90c 100644 --- a/freak/templates/base.html +++ b/freak/templates/base.html @@ -47,7 +47,7 @@ {% elif current_user.is_authenticated %}
  • - + {{ icon('add') }} New post From a40d95922278f2699fc2509a0b6090e4f0da6c90 Mon Sep 17 00:00:00 2001 From: Yusur Princeps Date: Mon, 21 Jul 2025 00:25:23 +0200 Subject: [PATCH 37/73] 0.4.0.rc1: version advance, dep. bump --- CHANGELOG.md | 4 ++-- freak/__init__.py | 2 +- freak/static/sass/layout.sass | 4 ++++ pyproject.toml | 2 +- 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index be61e29..0177537 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,13 +12,13 @@ - Moderators (and admins) have now access to mod tools + Allowed operations: change display name, description, restriction status, and exile (guild-local ban) members + Site administrators and guild owners can add moderators -- Guilds can have restricted posting/commenting now. Unmoderated guilds always have. +- Guilds can have restricted posting/commenting now. Unmoderated guilds always have - Administrators can claim ownership of abandoned guilds - Admins can now suspend users from admin panel - Implemented guild subscriptions (not as in $$$, yes as in the follow button) - Minimum karma requirement for creating a guild is now configurable via env variable `FREAK_CREATE_GUILD_THRESHOLD` (previously hardcoded at 15) - Users can now set their display name, biography and color theme in `/settings` -- Impressum can now be set in .env, e.g. `IMPRESSUM='Acme Ltd.::1 Short Island::Old York, Divided States::Responsible: ::Donald Duck'` Lines are separated by two colons. **Versions before 0.4.0 CAN'T BE RUN in German-speaking countries** as of 2025. +- Impressum can now be set in .env, e.g. `IMPRESSUM='Acme Ltd.::1 Short Island::Old York, Divided States::Responsible: ::Donald Duck'` Lines are separated by two colons. **Versions before 0.4.0 CAN'T BE RUN in German-speaking countries** as of 2025 - Several aesthetic improvements ## 0.3.3 diff --git a/freak/__init__.py b/freak/__init__.py index e84954c..4c26d62 100644 --- a/freak/__init__.py +++ b/freak/__init__.py @@ -24,7 +24,7 @@ from suou.configparse import ConfigOptions, ConfigValue from .colors import color_themes, theme_classes from .utils import twocolon_list -__version__ = '0.4.0-dev28' +__version__ = '0.4.0.rc1' APP_BASE_DIR = os.path.dirname(os.path.dirname(__file__)) diff --git a/freak/static/sass/layout.sass b/freak/static/sass/layout.sass index ae79fd1..a2c70eb 100644 --- a/freak/static/sass/layout.sass +++ b/freak/static/sass/layout.sass @@ -397,3 +397,7 @@ textarea.create_text width: 100% padding: 0 + +label:has([type="checkbox"]:not(:checked)) + opacity: .75 + diff --git a/pyproject.toml b/pyproject.toml index ce9489b..48c04f8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,7 +18,7 @@ dependencies = [ "PsycoPG2-binary", "libsass", "setuptools>=78.1.0", - "sakuragasaki46-suou>=0.3.5" + "sakuragasaki46-suou>=0.3.7" ] requires-python = ">=3.10" classifiers = [ From 1608b063562b7b5a321e89a73ffb416e1903bdaa Mon Sep 17 00:00:00 2001 From: Yusur Princeps Date: Tue, 22 Jul 2025 15:43:34 +0200 Subject: [PATCH 38/73] 0.4.0, bump requirements --- freak/__init__.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/freak/__init__.py b/freak/__init__.py index 4c26d62..607b400 100644 --- a/freak/__init__.py +++ b/freak/__init__.py @@ -24,7 +24,7 @@ from suou.configparse import ConfigOptions, ConfigValue from .colors import color_themes, theme_classes from .utils import twocolon_list -__version__ = '0.4.0.rc1' +__version__ = '0.4.0' APP_BASE_DIR = os.path.dirname(os.path.dirname(__file__)) diff --git a/pyproject.toml b/pyproject.toml index 48c04f8..b26dfd7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,7 +18,7 @@ dependencies = [ "PsycoPG2-binary", "libsass", "setuptools>=78.1.0", - "sakuragasaki46-suou>=0.3.7" + "sakuragasaki46-suou>=0.4.0" ] requires-python = ">=3.10" classifiers = [ From b97355bb89aa01e54355c9593fd40692ac3debf3 Mon Sep 17 00:00:00 2001 From: Yusur Princeps Date: Wed, 30 Jul 2025 01:49:14 +0200 Subject: [PATCH 39/73] version advance --- freak/__init__.py | 2 +- freak/templates/base.html | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/freak/__init__.py b/freak/__init__.py index 607b400..34c4cf8 100644 --- a/freak/__init__.py +++ b/freak/__init__.py @@ -24,7 +24,7 @@ from suou.configparse import ConfigOptions, ConfigValue from .colors import color_themes, theme_classes from .utils import twocolon_list -__version__ = '0.4.0' +__version__ = '0.5.0-dev30' APP_BASE_DIR = os.path.dirname(os.path.dirname(__file__)) diff --git a/freak/templates/base.html b/freak/templates/base.html index a4eb90c..e747247 100644 --- a/freak/templates/base.html +++ b/freak/templates/base.html @@ -2,7 +2,6 @@ - {% from "macros/icon.html" import icon with context %} {% block title %} From 73b5b7993fb5dd4c4adf55bf04edd162a6fc5798 Mon Sep 17 00:00:00 2001 From: Yusur Princeps Date: Sat, 23 Aug 2025 19:04:28 +0200 Subject: [PATCH 40/73] switch to Quart framework --- CHANGELOG.md | 6 + README.md | 2 +- docker-run.sh | 2 +- freak/__init__.py | 240 +++++++++++++---- freak/__main__.py | 4 +- freak/ajax.py | 200 +++++++------- freak/algorithms.py | 36 +-- freak/cli.py | 17 +- freak/colors.py | 2 +- freak/dei.py | 77 ------ freak/models.py | 356 ++++++++++++++++--------- freak/rest/__init__.py | 87 +++--- freak/search.py | 25 -- freak/templates/about.html | 8 +- freak/templates/admin/admin_users.html | 2 +- freak/templates/base.html | 11 +- freak/templates/guildsettings.html | 2 +- freak/templates/macros/colors.html | 0 freak/templates/macros/create.html | 2 +- freak/templates/macros/feed.html | 10 +- freak/templates/macros/nav.html | 16 +- freak/templates/singledelete.html | 4 +- freak/templates/singlepost.html | 6 +- freak/templates/terms.html | 5 +- freak/templates/userfeed.html | 2 +- freak/utils.py | 19 +- freak/website/about.py | 25 +- freak/website/accounts.py | 225 ++++++++++------ freak/website/admin.py | 195 +++++++------- freak/website/create.py | 127 ++++----- freak/website/delete.py | 40 +-- freak/website/detail.py | 136 ++++++---- freak/website/edit.py | 47 ++-- freak/website/frontpage.py | 73 +++-- freak/website/moderation.py | 85 +++--- freak/website/reports.py | 84 +++--- pyproject.toml | 17 +- robots.txt | 2 +- 38 files changed, 1259 insertions(+), 938 deletions(-) delete mode 100644 freak/dei.py delete mode 100644 freak/templates/macros/colors.html diff --git a/CHANGELOG.md b/CHANGELOG.md index 0177537..2a73a68 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 0.5.0 + +- Switched to Quart frontend +- **BREAKING**: `SERVER_NAME` env variable now contains the domain name. `DOMAIN_NAME` has been removed. +- libsuou bumped to 0.5.0 + ## 0.4.0 - Added dependency to [SUOU](https://github.com/sakuragasaki46/suou) library diff --git a/README.md b/README.md index 1308d66..e2c9f28 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ * Will to not give up. * Clone this repository. * Fill in `.env` with the necessary information. - * `DOMAIN_NAME` (see above) + * `SERVER_NAME` (see above) * `APP_NAME` * `DATABASE_URL` (hint: `postgresql://username:password@localhost/dbname`) * `SECRET_KEY` (you can generate one with the command `cat /dev/random | tr -dc A-Za-z0-9_. | head -c 56`) diff --git a/docker-run.sh b/docker-run.sh index 331cbe1..9dbe0e7 100644 --- a/docker-run.sh +++ b/docker-run.sh @@ -6,7 +6,7 @@ start-app() { cp -rv /opt/live-app/{freak,pyproject.toml,docker-run.sh} ./ cp -v /opt/live-app/.env.prod .env pip install -e . - flask --app freak run --host=0.0.0.0 + hypercorn freak:app -b 0.0.0.0:5000 } [[ "$1" = "" ]] && start-app diff --git a/freak/__init__.py b/freak/__init__.py index 34c4cf8..15b37ba 100644 --- a/freak/__init__.py +++ b/freak/__init__.py @@ -1,30 +1,32 @@ +import logging import re from sqlite3 import ProgrammingError +import sys from typing import Any import warnings -from flask import ( - Flask, g, redirect, render_template, +from quart import ( + Quart, flash, g, jsonify, redirect, render_template, request, send_from_directory, url_for ) import os import dotenv -from flask_login import LoginManager -from flask_wtf.csrf import CSRFProtect -from sqlalchemy import select -from sqlalchemy.exc import SQLAlchemyError +from quart_auth import AuthUser, QuartAuth, Action as QA_Action, current_user +from quart_wtf import CSRFProtect +from sqlalchemy import inspect, select from suou import Snowflake, ssv_list from werkzeug.routing import BaseConverter -from sassutils.wsgi import SassMiddleware -from werkzeug.middleware.proxy_fix import ProxyFix +from suou.sass import SassAsyncMiddleware +from suou.quart import negotiate +from hypercorn.middleware import ProxyFixMiddleware from suou.configparse import ConfigOptions, ConfigValue +from suou import twocolon_list, WantsContentType from .colors import color_themes, theme_classes -from .utils import twocolon_list -__version__ = '0.5.0-dev30' +__version__ = '0.5.0-dev33' APP_BASE_DIR = os.path.dirname(os.path.dirname(__file__)) @@ -35,31 +37,88 @@ class AppConfig(ConfigOptions): secret_key = ConfigValue(required=True) database_url = ConfigValue(required=True) app_name = ConfigValue() - domain_name = ConfigValue() + server_name = ConfigValue() private_assets = ConfigValue(cast=ssv_list) jquery_url = ConfigValue(default='https://ajax.googleapis.com/ajax/libs/jquery/3.7.1/jquery.min.js') - app_is_behind_proxy = ConfigValue(cast=bool, default=False) + app_is_behind_proxy = ConfigValue(cast=int, default=0) impressum = ConfigValue(cast=twocolon_list, default='') create_guild_threshold = ConfigValue(cast=int, default=15, prefix='freak_') app_config = AppConfig() -app = Flask(__name__) +logging.basicConfig(level=logging.WARNING) + +logger = logging.getLogger(__name__) + +app = Quart(__name__) app.secret_key = app_config.secret_key app.config['SQLALCHEMY_DATABASE_URI'] = app_config.database_url app.config['JSONIFY_PRETTYPRINT_REGULAR'] = False +app.config['QUART_AUTH_DURATION'] = 365 * 24 * 60 * 60 +app.config['SERVER_NAME'] = app_config.server_name -from .models import db, User, Post +class UserLoader(AuthUser): + """ + Loads user from the session. + + *WARNING* requires to be awaited before request before usage! + + Actual User object is at .user; other attributes are proxied. + """ + def __init__(self, auth_id: str | None, action: QA_Action= QA_Action.PASS): + self._auth_id = auth_id + self._auth_obj = None + self._auth_sess = None + self.action = action + + @property + def auth_id(self) -> str | None: + return self._auth_id + + @property + async def is_authenticated(self) -> bool: + await self._load() + return self._auth_id is not None + + async def _load(self): + if self._auth_obj is None and self._auth_id is not None: + session = self._auth_sess = await db.begin() + self._auth_obj = (await session.execute(select(User).where(User.id == int(self._auth_id)))).scalar() + if self._auth_obj is None: + raise RuntimeError('failed to fetch user') + + def __getattr__(self, key): + if self._auth_obj is None: + raise RuntimeError('user is not loaded') + return getattr(self._auth_obj, key) + + def __bool__(self): + return self._auth_obj is not None + + async def _unload(self): + # user is not expected to mutate + if self._auth_sess: + await self._auth_sess.rollback() + + @property + def user(self): + return self._auth_obj + + id: int + +## DO NOT ADD LOCAL IMPORTS BEFORE THIS LINE + +from .models import Guild, db, User, Post # SASS -app.wsgi_app = SassMiddleware(app.wsgi_app, dict( +app.asgi_app = SassAsyncMiddleware(app.asgi_app, dict( freak=('static/sass', 'static/css', '/static/css', True) )) # proxy fix if app_config.app_is_behind_proxy: - app.wsgi_app = ProxyFix( - app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1 + app.asgi_app = ProxyFixMiddleware( + app.asgi_app, trusted_hops=app_config.app_is_behind_proxy, mode='legacy' ) class SlugConverter(BaseConverter): @@ -75,100 +134,167 @@ class B32lConverter(BaseConverter): app.url_map.converters['slug'] = SlugConverter app.url_map.converters['b32l'] = B32lConverter -db.init_app(app) +db.bind(app_config.database_url) csrf = CSRFProtect(app) -login_manager = LoginManager(app) -login_manager.login_view = 'accounts.login' + + + +# TODO configure quart_auth +login_manager = QuartAuth(app, user_class= UserLoader) from . import filters PRIVATE_ASSETS = os.getenv('PRIVATE_ASSETS', '').split() +post_count_cache = 0 +user_count_cache = 0 + @app.context_processor -def _inject_variables(): +async def _inject_variables(): + global post_count_cache, user_count_cache + try: + post_count = await Post.count() + user_count = await User.active_count() + except Exception as e: + logger.error(f'cannot compute post_count: {e}') + post_count = post_count_cache + user_count = user_count_cache + else: + post_count_cache = post_count + user_count_cache = user_count + return { 'app_name': app_config.app_name, 'app_version': __version__, - 'domain_name': app_config.domain_name, + 'server_name': app_config.server_name, 'url_for_css': (lambda name, **kwargs: url_for('static', filename=f'css/{name}.css', **kwargs)), 'private_scripts': [x for x in app_config.private_assets if x.endswith('.js')], 'private_styles': [x for x in PRIVATE_ASSETS if x.endswith('.css')], 'jquery_url': app_config.jquery_url, - 'post_count': Post.count(), - 'user_count': User.active_count(), + 'post_count': post_count, + 'user_count': user_count, 'colors': color_themes, 'theme_classes': theme_classes, 'impressum': '\n'.join(app_config.impressum).replace('_', ' ') } -@login_manager.user_loader -def _inject_user(userid): +@app.before_request +async def _load_user(): try: - u = db.session.execute(select(User).where(User.id == userid)).scalar() - if u is None or u.is_disabled: - return None - return u - except SQLAlchemyError as e: - warnings.warn(f'cannot retrieve user {userid} from db (exception: {e})', RuntimeWarning) + await current_user._load() + except RuntimeError as e: + logger.error(f'{e}') g.no_user = True - return None + +@app.after_request +async def _unload_request(resp): + try: + await current_user._unload() + except RuntimeError as e: + logger.error(f'{e}') + return resp + def redact_url_password(u: str | Any) -> str | Any: if not isinstance(u, str): return u return re.sub(r':[^@:/ ]+@', ':***@', u) +async def error_handler_for(status: int, message: str, template: str): + match negotiate(): + case WantsContentType.JSON: + return jsonify({'error': f'{message}', 'status': status}), status + case WantsContentType.HTML: + return await render_template(template, message=f'{message}'), status + case WantsContentType.PLAIN: + return f'{message} (HTTP {status})', status, {'content-type': 'text/plain; charset=UTF-8'} + @app.errorhandler(ProgrammingError) -def error_db(body): +async def error_db(body): g.no_user = True - warnings.warn(f'No database access! (url is {redact_url_password(app.config['SQLALCHEMY_DATABASE_URI'])!r})', RuntimeWarning) - return render_template('500.html'), 500 + logger.error(f'No database access! (url is {redact_url_password(app.config['SQLALCHEMY_DATABASE_URI'])!r})', RuntimeWarning) + return await error_handler_for(500, body, '500.html') @app.errorhandler(400) -def error_400(body): - return render_template('400.html'), 400 +async def error_400(body): + return await error_handler_for(400, body, '400.html') + +@app.errorhandler(401) +async def error_401(body): + match negotiate(): + case WantsContentType.HTML: + return redirect(url_for('accounts.login', next=request.path)) + case _: + return await error_handler_for(401, 'Please log in.', 'login.html') + @app.errorhandler(403) -def error_403(body): - return render_template('403.html'), 403 +async def error_403(body): + return await error_handler_for(403, body, '403.html') -from .search import find_guild_or_user +async def find_guild_or_user(name: str) -> str | None: + """ + Used in 404 error handler. + + Returns an URL to redirect or None for no redirect. + """ + + if hasattr(g, 'no_user'): + return None + + # do not execute for non-browsers_ + if 'Mozilla/' not in request.user_agent.string: + return None + + async with db as session: + gu = (await session.execute(select(Guild).where(Guild.name == name))).scalar() + user = (await session.execute(select(User).where(User.username == name))).scalar() + + if gu is not None: + await flash(f'There is nothing at /{name}. Luckily, a guild with name {gu.handle()} happens to exist. Next time, remember to add + before!') + return gu.url() + + if user is not None: + await flash(f'There is nothing at /{name}. Luckily, a user named {user.handle()} happens to exist. Next time, remember to add @ before!') + return user.url() + + return None @app.errorhandler(404) -def error_404(body): +async def error_404(body): try: if mo := re.match(r'/([a-z0-9_-]+)/?', request.path): - alternative = find_guild_or_user(mo.group(1)) + alternative = await find_guild_or_user(mo.group(1)) if alternative is not None: return redirect(alternative), 302 except Exception as e: - warnings.warn(f'Exception in find_guild_or_user: {e}') + logger.error(f'Exception in find_guild_or_user: {e}') pass - return render_template('404.html'), 404 + return await error_handler_for(404, 'Not found', '404.html') @app.errorhandler(405) -def error_405(body): - return render_template('405.html'), 405 +async def error_405(body): + return await error_handler_for(405, body, '405.html') @app.errorhandler(451) -def error_451(body): - return render_template('451.html'), 451 +async def error_451(body): + return await error_handler_for(451, body, '451.html') @app.errorhandler(500) -def error_500(body): +async def error_500(body): g.no_user = True - return render_template('500.html'), 500 + return await error_handler_for(500, body, '500.html') @app.route('/favicon.ico') -def favicon_ico(): - return send_from_directory(APP_BASE_DIR, 'favicon.ico') +async def favicon_ico(): + return await send_from_directory(APP_BASE_DIR, 'favicon.ico') @app.route('/robots.txt') -def robots_txt(): - return send_from_directory(APP_BASE_DIR, 'robots.txt') +async def robots_txt(): + return await send_from_directory(APP_BASE_DIR, 'robots.txt') from .website import blueprints @@ -178,8 +304,8 @@ for bp in blueprints: from .ajax import bp app.register_blueprint(bp) -from .rest import rest_bp -app.register_blueprint(rest_bp) +from .rest import bp +app.register_blueprint(bp) diff --git a/freak/__main__.py b/freak/__main__.py index df77c43..0f15538 100644 --- a/freak/__main__.py +++ b/freak/__main__.py @@ -1,4 +1,6 @@ +import asyncio from .cli import main -main() \ No newline at end of file +asyncio.run(main()) + diff --git a/freak/ajax.py b/freak/ajax.py index 00107ed..19e964c 100644 --- a/freak/ajax.py +++ b/freak/ajax.py @@ -1,29 +1,35 @@ ''' -AJAX hooks for the website. +AJAX hooks for the OLD frontend. -2025 DEPRECATED in favor of /v1/ (REST) +DEPRECATED in 0.5 in favor of /v1/ (REST) ''' +from __future__ import annotations + import re -from flask import Blueprint, abort, flash, redirect, request +from quart import Blueprint, abort, flash, redirect, request from sqlalchemy import delete, insert, select -from .models import Guild, Member, UserBlock, db, User, Post, PostUpvote, username_is_legal -from flask_login import current_user, login_required -current_user: User +from freak import UserLoader +from freak.utils import get_request_form +from .models import Guild, Member, UserBlock, db, User, Post, PostUpvote, username_is_legal +from quart_auth import current_user, login_required + +current_user: UserLoader bp = Blueprint('ajax', __name__) @bp.route('/username_availability/') @bp.route('/ajax/username_availability/') -def username_availability(username: str): +async def username_availability(username: str): is_valid = username_is_legal(username) if is_valid: - user = db.session.execute(select(User).where(User.username == username)).scalar() + async with db as session: + user = (await session.execute(select(User).where(User.username == username))).scalar() - is_available = user is None or user == current_user + is_available = user is None or user == current_user.user else: is_available = False @@ -34,13 +40,14 @@ def username_availability(username: str): } @bp.route('/guild_name_availability/') -def guild_name_availability(name: str): +async def guild_name_availability(name: str): is_valid = username_is_legal(name) if is_valid: - gd = db.session.execute(select(Guild).where(Guild.name == name)).scalar() + async with db as session: + gd = (await session.execute(select(Guild).where(Guild.name == name))).scalar() - is_available = gd is None + is_available = gd is None else: is_available = False @@ -52,101 +59,112 @@ def guild_name_availability(name: str): @bp.route('/comments//upvote', methods=['POST']) @login_required -def post_upvote(id): - o = request.form['o'] - p: Post | None = db.session.execute(select(Post).where(Post.id == id)).scalar() +async def post_upvote(id): + form = await get_request_form() + o = form['o'] + async with db as session: + p: Post | None = (await session.execute(select(Post).where(Post.id == id))).scalar() - if p is None: - return { 'status': 'fail', 'message': 'Post not found' }, 404 - - if o == '1': - db.session.execute(delete(PostUpvote).where(PostUpvote.c.post_id == p.id, PostUpvote.c.voter_id == current_user.id, PostUpvote.c.is_downvote == True)) - db.session.execute(insert(PostUpvote).values(post_id = p.id, voter_id = current_user.id, is_downvote = False)) - elif o == '0': - db.session.execute(delete(PostUpvote).where(PostUpvote.c.post_id == p.id, PostUpvote.c.voter_id == current_user.id)) - elif o == '-1': - db.session.execute(delete(PostUpvote).where(PostUpvote.c.post_id == p.id, PostUpvote.c.voter_id == current_user.id, PostUpvote.c.is_downvote == False)) - db.session.execute(insert(PostUpvote).values(post_id = p.id, voter_id = current_user.id, is_downvote = True)) - else: - return { 'status': 'fail', 'message': 'Invalid score' }, 400 + if p is None: + return { 'status': 'fail', 'message': 'Post not found' }, 404 + + cur_score = await p.upvoted_by(current_user.user) - db.session.commit() - return { 'status': 'ok', 'count': p.upvotes() } + match (o, cur_score): + case ('1', 0) | ('1', -1): + await session.execute(delete(PostUpvote).where(PostUpvote.c.post_id == p.id, PostUpvote.c.voter_id == current_user.id, PostUpvote.c.is_downvote == True)) + await session.execute(insert(PostUpvote).values(post_id = p.id, voter_id = current_user.id, is_downvote = False)) + case ('0', _): + await session.execute(delete(PostUpvote).where(PostUpvote.c.post_id == p.id, PostUpvote.c.voter_id == current_user.id)) + case ('-1', 1) | ('-1', 0): + await session.execute(delete(PostUpvote).where(PostUpvote.c.post_id == p.id, PostUpvote.c.voter_id == current_user.id, PostUpvote.c.is_downvote == False)) + await session.execute(insert(PostUpvote).values(post_id = p.id, voter_id = current_user.id, is_downvote = True)) + case ('1', 1) | ('-1', -1): + pass + case _: + await session.rollback() + return { 'status': 'fail', 'message': 'Invalid score' }, 400 + + await session.commit() + return { 'status': 'ok', 'count': await p.upvotes() } @bp.route('/@/block', methods=['POST']) @login_required -def block_user(username): - u = db.session.execute(select(User).where(User.username == username)).scalar() - - if u is None: - abort(404) - - is_block = 'reverse' not in request.form - is_unblock = request.form.get('reverse') == '1' +async def block_user(username): + form = await get_request_form() - if is_block: - if current_user.has_blocked(u): - flash(f'{u.handle()} is already blocked') - else: - db.session.execute(insert(UserBlock).values( - actor_id = current_user.id, - target_id = u.id - )) - db.session.commit() - flash(f'{u.handle()} is now blocked') - - if is_unblock: - if not current_user.has_blocked(u): - flash('You didn\'t block this user') - else: - db.session.execute(delete(UserBlock).where( - UserBlock.c.actor_id == current_user.id, - UserBlock.c.target_id == u.id - )) - db.session.commit() - flash(f'Removed block on {u.handle()}') + async with db as session: + u = (await session.execute(select(User).where(User.username == username))).scalar() + if u is None: + abort(404) + + is_block = 'reverse' not in form + is_unblock = form.get('reverse') == '1' + + if is_block: + if current_user.has_blocked(u): + await flash(f'{u.handle()} is already blocked') + else: + await session.execute(insert(UserBlock).values( + actor_id = current_user.id, + target_id = u.id + )) + await flash(f'{u.handle()} is now blocked') + + if is_unblock: + if not current_user.has_blocked(u): + await flash('You didn\'t block this user') + else: + await session.execute(delete(UserBlock).where( + UserBlock.c.actor_id == current_user.id, + UserBlock.c.target_id == u.id + )) + await flash(f'Removed block on {u.handle()}') + return redirect(request.args.get('next', u.url())), 303 @bp.route('/+/subscribe', methods=['POST']) @login_required -def subscribe_guild(name): - gu = db.session.execute(select(Guild).where(Guild.name == name)).scalar() +async def subscribe_guild(name): + form = await get_request_form() - if gu is None: - abort(404) - - is_join = 'reverse' not in request.form - is_leave = request.form.get('reverse') == '1' + async with db as session: + gu = (await session.execute(select(Guild).where(Guild.name == name))).scalar() - membership = db.session.execute(select(Member).where(Member.guild == gu, Member.user_id == current_user.id)).scalar() + if gu is None: + abort(404) + + is_join = 'reverse' not in form + is_leave = form.get('reverse') == '1' - if is_join: - if membership is None: - membership = db.session.execute(insert(Member).values( - guild_id = gu.id, - user_id = current_user.id, - is_subscribed = True - ).returning(Member)).scalar() - elif membership.is_subscribed == False: - membership.is_subscribed = True - db.session.add(membership) - else: - return redirect(gu.url()), 303 - db.session.commit() - flash(f"You are now subscribed to {gu.handle()}") + membership = (await session.execute(select(Member).where(Member.guild == gu, Member.user_id == current_user.id))).scalar() - if is_leave: - if membership is None: - return redirect(gu.url()), 303 - elif membership.is_subscribed == True: - membership.is_subscribed = False - db.session.add(membership) - else: - return redirect(gu.url()), 303 + if is_join: + if membership is None: + membership = (await session.execute(insert(Member).values( + guild_id = gu.id, + user_id = current_user.id, + is_subscribed = True + ).returning(Member))).scalar() + elif membership.is_subscribed == False: + membership.is_subscribed = True + await session.add(membership) + else: + return redirect(gu.url()), 303 + await flash(f"You are now subscribed to {gu.handle()}") - db.session.commit() - flash(f"Unsubscribed from {gu.handle()}.") + if is_leave: + if membership is None: + return redirect(gu.url()), 303 + elif membership.is_subscribed == True: + membership.is_subscribed = False + await session.add(membership) + else: + return redirect(gu.url()), 303 + + await session.commit() + await flash(f"Unsubscribed from {gu.handle()}.") return redirect(gu.url()), 303 diff --git a/freak/algorithms.py b/freak/algorithms.py index 04d8258..911b905 100644 --- a/freak/algorithms.py +++ b/freak/algorithms.py @@ -2,15 +2,16 @@ from flask_login import current_user from sqlalchemy import and_, distinct, func, select -from .models import Comment, Member, db, Post, Guild, User -current_user: User +from .models import Comment, Member, Post, Guild, User + + def cuser() -> User: - return current_user if current_user.is_authenticated else None + return current_user.user if current_user else None def cuser_id() -> int: - return current_user.id if current_user.is_authenticated else None + return current_user.id if current_user else None def public_timeline(): return select(Post).join(User, User.id == Post.author_id).where( @@ -18,24 +19,25 @@ def public_timeline(): ).order_by(Post.created_at.desc()) def topic_timeline(gname): - return select(Post).join(Guild).join(User, User.id == Post.author_id).where( + return select(Post).join(Guild, Guild.id == Post.topic_id).join(User, User.id == Post.author_id).where( Post.privacy == 0, Guild.name == gname, User.not_suspended(), Post.not_removed(), User.has_not_blocked(Post.author_id, cuser_id()) ).order_by(Post.created_at.desc()) -def user_timeline(user_id): +def user_timeline(user: User): return select(Post).join(User, User.id == Post.author_id).where( - Post.visible_by(cuser_id()), User.id == user_id, User.not_suspended(), Post.not_removed(), User.has_not_blocked(Post.author_id, cuser_id()) + Post.visible_by(cuser_id()), Post.author_id == user.id, User.not_suspended(), Post.not_removed(), User.has_not_blocked(Post.author_id, cuser_id()) ).order_by(Post.created_at.desc()) -def top_guilds_query(): - q_post_count = func.count(distinct(Post.id)).label('post_count') - q_sub_count = func.count(distinct(Member.id)).label('sub_count') - qr = select(Guild, q_post_count, q_sub_count)\ - .join(Post, Post.topic_id == Guild.id, isouter=True)\ - .join(Member, and_(Member.guild_id == Guild.id, Member.is_subscribed == True), isouter=True)\ - .group_by(Guild).having(q_post_count > 5).order_by(q_post_count.desc(), q_sub_count.desc()) - return qr - def new_comments(p: Post): - return select(Comment).where(Comment.parent_post_id == p.id, Comment.parent_comment_id == None, + return select(Comment).join(Post, Post.id == Comment.parent_post_id).join(User, User.id == Comment.author_id).where(Comment.parent_post_id == p.id, Comment.parent_comment_id == None, Comment.not_removed(), User.has_not_blocked(Comment.author_id, cuser_id())).order_by(Comment.created_at.desc()) + + +class Algorithms: + """ + Return SQL queries for algorithms. + """ + def __init__(self, me: User | None): + self.me = me + + \ No newline at end of file diff --git a/freak/cli.py b/freak/cli.py index 34a1959..63b508a 100644 --- a/freak/cli.py +++ b/freak/cli.py @@ -6,7 +6,7 @@ import subprocess from sqlalchemy import create_engine, select from sqlalchemy.orm import Session -from . import __version__ as version, app +from . import __version__ as version, app_config from .models import User, db def make_parser(): @@ -16,7 +16,7 @@ def make_parser(): parser.add_argument('--flush', '-H', action='store_true', help='recompute karma for all users') return parser -def main(): +async def main(): args = make_parser().parse_args() engine = create_engine(os.getenv('DATABASE_URL')) @@ -26,18 +26,19 @@ def main(): print(f'Schema upgrade failed (code: {ret_code})') exit(ret_code) # if the alembic/versions folder is empty - db.metadata.create_all(engine) + await db.create_all(engine) print('Schema upgraded!') if args.flush: cnt = 0 - with app.app_context(): - for u in db.session.execute(select(User)).scalars(): + async with db as session: + + for u in (await session.execute(select(User))).scalars(): u.recompute_karma() cnt += 1 - db.session.add(u) - db.session.commit() + session.add(u) + session.commit() print(f'Recomputed karma of {cnt} users') - print(f'Visit ') + print(f'Visit ') diff --git a/freak/colors.py b/freak/colors.py index 2391f87..39171eb 100644 --- a/freak/colors.py +++ b/freak/colors.py @@ -21,7 +21,7 @@ color_themes = [ ColorTheme(10, 'Defoko'), ColorTheme(11, 'Kaito'), ColorTheme(12, 'Meiko'), - ColorTheme(13, 'Leek'), + ColorTheme(13, 'WhatsApp'), ColorTheme(14, 'Teto'), ColorTheme(15, 'Ruby') ] diff --git a/freak/dei.py b/freak/dei.py deleted file mode 100644 index 8ddebb4..0000000 --- a/freak/dei.py +++ /dev/null @@ -1,77 +0,0 @@ -""" -Utilities for Diversity, Equity, Inclusion -""" - -from __future__ import annotations - - -BRICKS = '@abcdefghijklmnopqrstuvwxyz+?-\'/' -# legend @: space, -: literal, +: suffix (i.e. ae+r expands to ae/aer), ': literal, ?: unknown, /: separator - -class Pronoun(int): - PRESETS = { - 'hh': 'he/him', - 'sh': 'she/her', - 'tt': 'they/them', - 'ii': 'it/its', - 'hs': 'he/she', - 'ht': 'he/they', - 'hi': 'he/it', - 'shh': 'she/he', - 'st': 'she/they', - 'si': 'she/it', - 'th': 'they/he', - 'ts': 'they/she', - 'ti': 'they/it', - } - - UNSPECIFIED = 0 - - ## presets from PronounDB - ## DO NOT TOUCH the values unless you know their exact correspondence!! - ## hint: Pronoun.from_short() - HE = HE_HIM = 264 - SHE = SHE_HER = 275 - THEY = THEY_THEM = 660 - IT = IT_ITS = 297 - HE_SHE = 616 - HE_THEY = 648 - HE_IT = 296 - SHE_HE = 8467 - SHE_THEY = 657 - SHE_IT = 307 - THEY_HE = 276 - THEY_SHE = 628 - THEY_IT = 308 - ANY = 26049 - OTHER = 19047055 - ASK = 11873 - AVOID = NAME_ONLY = 4505281 - - def short(self) -> str: - i = self - s = '' - while i > 0: - s += BRICKS[i % 32] - i >>= 5 - return s - - def full(self): - s = self.short() - - if s in self.PRESETS: - return self.PRESETS[s] - - if '+' in s: - s1, s2 = s.rsplit('+') - s = s1 + '/' + s1 + s2 - - return s - __str__ = full - - @classmethod - def from_short(self, s: str) -> Pronoun: - i = 0 - for j, ch in enumerate(s): - i += BRICKS.index(ch) << (5 * j) - return Pronoun(i) diff --git a/freak/models.py b/freak/models.py index caf4e72..86e77db 100644 --- a/freak/models.py +++ b/freak/models.py @@ -8,22 +8,31 @@ from functools import partial from operator import or_ import re from threading import Lock -from sqlalchemy import Column, Integer, String, ForeignKey, UniqueConstraint, insert, text, \ +from typing import Any, Callable +from quart_auth import AuthUser, current_user +from sqlalchemy import Column, ExceptionContext, Integer, String, ForeignKey, UniqueConstraint, and_, insert, text, \ CheckConstraint, Date, DateTime, Boolean, func, BigInteger, \ SmallInteger, select, update, Table from sqlalchemy.orm import Relationship, relationship -from flask_sqlalchemy import SQLAlchemy -from flask_login import AnonymousUserMixin -from suou import SiqType, Snowflake, Wanted, deprecated, not_implemented +from suou.sqlalchemy_async import SQLAlchemy +from suou import SiqType, Snowflake, Wanted, deprecated, makelist, not_implemented from suou.sqlalchemy import create_session, declarative_base, id_column, parent_children, snowflake_column from werkzeug.security import check_password_hash -from freak import app_config -from .utils import age_and_days, get_remote_addr, timed_cache +from . import UserLoader, app_config +from .utils import get_remote_addr +from suou import timed_cache, age_and_days + +current_user: UserLoader + +import logging + +logger = logging.getLogger(__name__) ## Constants and enums +## NOT IN USE: User has .banned_at and .is_disabled_by_user USER_ACTIVE = 0 USER_INACTIVE = 1 USER_BANNED = 2 @@ -71,16 +80,16 @@ ILLEGAL_USERNAMES = tuple(( 'me everyone here room all any server app dev devel develop nil none ' 'founder owner admin administrator mod modteam moderator sysop some ' ## fictitious users and automations - 'nobody deleted suspended default bot developer undefined null ' - 'ai automod automoderator assistant privacy anonymous removed assistance ' + 'nobody somebody deleted suspended default bot developer undefined null ' + 'ai automod clanker automoderator assistant privacy anonymous removed assistance ' ## law enforcement corps and slurs because yes 'pedo rape rapist nigger retard ncmec police cops 911 childsafety ' 'report dmca login logout security order66 gestapo ss hitler heilhitler kgb ' 'pedophile lolicon giphy tenor csam cp pedobear lolita lolice thanos ' - 'loli kkk pnf adl cop tranny google trustandsafety safety ice fbi nsa it ' + 'loli lolicon kkk pnf adl cop tranny google trustandsafety safety ice fbi nsa it ' ## VVVVIP 'potus realdonaldtrump elonmusk teddysphotos mrbeast jkrowling pewdiepie ' - 'elizabethii king queen pontifex hogwarts lumos alohomora isis daesh ' + 'elizabethii elizabeth2 king queen pontifex hogwarts lumos alohomora isis daesh retards ' ).split()) def username_is_legal(username: str) -> bool: @@ -94,21 +103,23 @@ def username_is_legal(username: str) -> bool: return False return True +def want_User(o: User | Any | None, *, prefix: str = '', var_name: str = '') -> User | None: + if isinstance(o, User): + return o + if o is None: + return None + logger.warning(f'{prefix}: {repr(var_name) + " has " if var_name else ""}invalid type {o.__class__.__name__}, expected User') + return None + ## END constants and enums -Base = declarative_base(app_config.domain_name, app_config.secret_key, +Base = declarative_base(app_config.server_name, app_config.secret_key, snowflake_epoch=1577833200) db = SQLAlchemy(model_class=Base) CSI = create_session_interactively = partial(create_session, app_config.database_url) -# the BaseModel() class will be removed in 0.5 -from .iding import new_id -@deprecated('id_column() and explicit id column are better. Will be removed in 0.5') -class BaseModel(Base): - __abstract__ = True - id = Column(BigInteger, primary_key=True, default=new_id) ## Many-to-many relationship keys for some reasons have to go ## BEFORE other table definitions. @@ -151,6 +162,7 @@ class User(Base): karma = Column(BigInteger, server_default=text('0'), nullable=False) legacy_id = Column(BigInteger, nullable=True) + # pronouns must be set via suou.dei.Pronoun.from_short() pronouns = Column(Integer, server_default=text('0'), nullable=False) biography = Column(String(1024), nullable=True) color_theme = Column(SmallInteger, nullable=False, server_default=text('0')) @@ -171,8 +183,8 @@ class User(Base): ## SQLAlchemy fail initialization of models — bricking the app. ## Posts are queried manually anyway #posts = relationship("Post", primaryjoin=lambda: #back_populates='author', pr) - upvoted_posts = relationship("Post", secondary=PostUpvote, back_populates='upvoters') - #comments = relationship("Comment", back_populates='author') + upvoted_posts = relationship("Post", secondary=PostUpvote, back_populates='upvoters', lazy='selectin') + #comments = relationship("Comment", back_populates='author', lazy='selectin') @property def is_disabled(self): @@ -189,13 +201,16 @@ class User(Base): return not self.is_disabled @property + @deprecated('shadowed by UserLoader.is_authenticated(), and always true') def is_authenticated(self): return True @property + @deprecated('no more in use since switch to Quart') def is_anonymous(self): return False + @deprecated('this representation uses decimal, URLs use b32l') def get_id(self): return str(self.id) @@ -215,17 +230,19 @@ class User(Base): id = Snowflake(self.id).to_b32l(), username = self.username, display_name = self.display_name, - age = self.age() - ## TODO add badges? + age = self.age(), + badges = self.badges() ) - def reward(self, points=1): + @deprecated('updates may be not atomic. DO NOT USE until further notice') + async def reward(self, points=1): """ Manipulate a user's karma on the fly """ with Lock(): - db.session.execute(update(User).where(User.id == self.id).values(karma = self.karma + points)) - db.session.commit() + async with db as session: + await session.execute(update(User).where(User.id == self.id).values(karma = self.karma + points)) + await session.commit() def can_create_guild(self): ## TODO make guild creation requirements fully configurable @@ -240,10 +257,12 @@ class User(Base): return check_password_hash(self.passhash, password) @classmethod - @timed_cache(1800) - def active_count(cls) -> int: + @timed_cache(1800, async_=True) + async def active_count(cls) -> int: active_th = datetime.datetime.now() - datetime.timedelta(days=30) - return db.session.execute(select(func.count(User.id)).select_from(cls).join(Post, Post.author_id == User.id).where(Post.created_at >= active_th).group_by(User.id)).scalar() + async with db as session: + count = (await session.execute(select(func.count(User.id)).select_from(cls).join(Post, Post.author_id == User.id).where(Post.created_at >= active_th).group_by(User.id))).scalar() + return count def __repr__(self): return f'<{self.__class__.__name__} id:{self.id!r} username:{self.username!r}>' @@ -252,10 +271,25 @@ class User(Base): def not_suspended(cls): return or_(User.banned_at == None, User.banned_until <= datetime.datetime.now()) - def has_blocked(self, other: User | None) -> bool: - if other is None or not other.is_authenticated: + async def has_blocked(self, other: User | None) -> bool: + if not want_User(other, var_name='other', prefix='User.has_blocked()'): return False - return bool(db.session.execute(select(UserBlock).where(UserBlock.c.actor_id == self.id, UserBlock.c.target_id == other.id)).scalar()) + async with db as session: + block_exists = (await session.execute(select(UserBlock).where(UserBlock.c.actor_id == self.id, UserBlock.c.target_id == other.id))).scalar() + return bool(block_exists) + + async def is_blocked_by(self, other: User | None) -> bool: + if not want_User(other, var_name='other', prefix='User.is_blocked_by()'): + return False + async with db as session: + block_exists = (await session.execute(select(UserBlock).where(UserBlock.c.actor_id == other.id, UserBlock.c.target_id == self.id))).scalar() + return bool(block_exists) + + def has_blocked_q(self, other_id: int): + return select(UserBlock).where(UserBlock.c.actor_id == self.id, UserBlock.c.target_id == other_id).exists() + + def blocked_by_q(self, other_id: int): + return select(UserBlock).where(UserBlock.c.actor_id == other_id, UserBlock.c.target_id == self.id).exists() @not_implemented() def end_friendship(self, other: User): @@ -268,10 +302,10 @@ class User(Base): def has_subscriber(self, other: User) -> bool: # TODO implement in 0.5 - return False #bool(db.session.execute(select(Friendship).where(...)).scalar()) + return False #bool(session.execute(select(Friendship).where(...)).scalar()) @classmethod - def has_not_blocked(cls, actor, target): + def has_not_blocked(cls, actor: int, target: int): """ Filter out a content if the author has blocked current user. Returns a query. @@ -285,33 +319,64 @@ class User(Base): qq= ~select(UserBlock).where(UserBlock.c.actor_id == actor_id, UserBlock.c.target_id == target_id).exists() return qq - def recompute_karma(self): - c = 0 - c += db.session.execute(select(func.count('*')).select_from(Post).where(Post.author == self)).scalar() - c += db.session.execute(select(func.count('*')).select_from(PostUpvote).join(Post).where(Post.author == self, PostUpvote.c.is_downvote == False)).scalar() - c -= db.session.execute(select(func.count('*')).select_from(PostUpvote).join(Post).where(Post.author == self, PostUpvote.c.is_downvote == True)).scalar() + async def recompute_karma(self): + """ + Recompute karma as of 0.4.0 karma handling + """ + async with db as session: + c = 0 + c += session.execute(select(func.count('*')).select_from(Post).where(Post.author == self)).scalar() + c += session.execute(select(func.count('*')).select_from(PostUpvote).join(Post).where(Post.author == self, PostUpvote.c.is_downvote == False)).scalar() + c -= session.execute(select(func.count('*')).select_from(PostUpvote).join(Post).where(Post.author == self, PostUpvote.c.is_downvote == True)).scalar() + self.karma = c - self.karma = c + return c - @timed_cache(60) - def strike_count(self) -> int: - return db.session.execute(select(func.count('*')).select_from(UserStrike).where(UserStrike.user_id == self.id)).scalar() + ## TODO are coroutines cacheable? + @timed_cache(60, async_=True) + async def strike_count(self) -> int: + async with db as session: + return (await session.execute(select(func.count('*')).select_from(UserStrike).where(UserStrike.user_id == self.id))).scalar() - def moderates(self, gu: Guild) -> bool: - ## owner - if gu.owner_id == self.id: - return True - ## admin or global mod - if self.is_administrator: - return True - memb = db.session.execute(select(Member).where(Member.user_id == self.id, Member.guild_id == gu.id)).scalar() + async def moderates(self, gu: Guild) -> bool: + async with db as session: + ## owner + if gu.owner_id == self.id: + return True + ## admin or global mod + if self.is_administrator: + return True + memb = (await session.execute(select(Member).where(Member.user_id == self.id, Member.guild_id == gu.id))).scalar() - if memb is None: - return False - return memb.is_moderator + if memb is None: + return False + return memb.is_moderator ## TODO check banship? + @makelist + def badges(self, /): + if self.is_administrator: + yield 'administrator' + + badges: Callable[[], list[str]] + + @classmethod + async def get_by_username(cls, name: str): + """ + Get a user by its username, + """ + user_q = select(User).where(User.username == name) + try: + if current_user: + user_q = user_q.where(~select(UserBlock).where(UserBlock.c.target_id == current_user.id).exists()) + except Exception as e: + logger.error(f'{e}') + + async with db as session: + user = (await session.execute(user_q)).scalar() + return user + # UserBlock table is at the top !! ## END User @@ -346,62 +411,76 @@ class Guild(Base): def handle(self): return f'+{self.name}' - def subscriber_count(self): - return db.session.execute(select(func.count('*')).select_from(Member).where(Member.guild == self, Member.is_subscribed == True)).scalar() + async def subscriber_count(self): + async with db as session: + count = (await session.execute(select(func.count('*')).select_from(Member).where(Member.guild == self, Member.is_subscribed == True))).scalar() + return count # utilities - owner = relationship(User, foreign_keys=owner_id) - posts = relationship('Post', back_populates='guild') + owner = relationship(User, foreign_keys=owner_id, lazy='selectin') + posts = relationship('Post', back_populates='guild', lazy='selectin') - def has_subscriber(self, other: User) -> bool: - if other is None or not other.is_authenticated: - return False - return bool(db.session.execute(select(Member).where(Member.user_id == other.id, Member.guild_id == self.id, Member.is_subscribed == True)).scalar()) + async def post_count(self): + async with db as session: + return (await session.execute(select(func.count('*')).select_from(Post).where(Post.guild == self))).scalar() - def has_exiled(self, other: User) -> bool: - if other is None or not other.is_authenticated: + async def has_subscriber(self, other: User) -> bool: + if not want_User(other, var_name='other', prefix='Guild.has_subscriber()'): return False - u = db.session.execute(select(Member).where(Member.user_id == other.id, Member.guild_id == self.id)).scalar() + async with db as session: + sub_ex = (await session.execute(select(Member).where(Member.user_id == other.id, Member.guild_id == self.id, Member.is_subscribed == True))).scalar() + return bool(sub_ex) + + async def has_exiled(self, other: User) -> bool: + if not want_User(other, var_name='other', prefix='Guild.has_exiled()'): + return False + async with db as session: + u = (await session.execute(select(Member).where(Member.user_id == other.id, Member.guild_id == self.id))).scalar() return u.is_banned if u else False - def allows_posting(self, other: User) -> bool: - if self.owner is None: - return False - if other.is_disabled: - return False - mem: Member | None = db.session.execute(select(Member).where(Member.user_id == other.id, Member.guild_id == self.id)).scalar() if other else None - if mem and mem.is_banned: - return False - if other.moderates(self): + async def allows_posting(self, other: User) -> bool: + async with db as session: + # control owner_id instead of owner: the latter causes MissingGreenletError + if self.owner_id is None: + return False + if other.is_disabled: + return False + mem: Member | None = (await session.execute(select(Member).where(Member.user_id == other.id, Member.guild_id == self.id))).scalar() + if mem and mem.is_banned: + return False + if await other.moderates(self): + return True + if self.is_restricted: + return (mem and mem.is_approved) return True - if self.is_restricted: - return (mem and mem.is_approved) - return True - - def moderators(self): - if self.owner: - yield ModeratorInfo(self.owner, True) - for mem in db.session.execute(select(Member).where(Member.guild_id == self.id, Member.is_moderator == True)).scalars(): - if mem.user != self.owner and not mem.is_banned: - yield ModeratorInfo(mem.user, False) + async def moderators(self): + async with db as session: + if self.owner_id: + owner = (await session.execute(select(User).where(User.id == self.owner_id))).scalar() + yield ModeratorInfo(owner, True) + for mem in (await session.execute(select(Member).where(Member.guild_id == self.id, Member.is_moderator == True))).scalars(): + if mem.user != self.owner and not mem.is_banned: + yield ModeratorInfo(mem.user, False) - def update_member(self, u: User | Member, /, **values): + async def update_member(self, u: User | Member, /, **values): if isinstance(u, User): - m = db.session.execute(select(Member).where(Member.user_id == u.id, Member.guild_id == self.id)).scalar() - if m is None: - m = db.session.execute(insert(Member).values( - guild_id = self.id, - user_id = u.id, - **values - ).returning(Member)).scalar() + async with db as session: + m = (await session.execute(select(Member).where(Member.user_id == u.id, Member.guild_id == self.id))).scalar() if m is None: - raise RuntimeError - return m + m = (await session.execute(insert(Member).values( + guild_id = self.id, + user_id = u.id, + **values + ).returning(Member))).scalar() + if m is None: + raise RuntimeError + return m else: m = u if len(values): - db.session.execute(update(Member).where(Member.user_id == u.id, Member.guild_id == self.id).values(**values)) + async with db as session: + session.execute(update(Member).where(Member.user_id == u.id, Member.guild_id == self.id).values(**values)) return m @@ -433,9 +512,9 @@ class Member(Base): banned_until = Column(DateTime, nullable=True) banned_message = Column(String(256), nullable=True) - user = relationship(User, primaryjoin = lambda: User.id == Member.user_id) - guild = relationship(Guild) - banned_by = relationship(User, primaryjoin = lambda: User.id == Member.banned_by_id) + user = relationship(User, primaryjoin = lambda: User.id == Member.user_id, lazy='selectin') + guild = relationship(Guild, lazy='selectin') + banned_by = relationship(User, primaryjoin = lambda: User.id == Member.banned_by_id, lazy='selectin') @property def is_banned(self): @@ -474,10 +553,14 @@ class Post(Base): removed_reason = Column(SmallInteger, nullable=True) # utilities - author: Relationship[User] = relationship("User", lazy='selectin', foreign_keys=[author_id])#, back_populates="posts") + author: Relationship[User] = relationship("User", foreign_keys=[author_id], lazy='selectin')#, back_populates="posts") guild: Relationship[Guild] = relationship("Guild", back_populates="posts", lazy='selectin') - comments = relationship("Comment", back_populates="parent_post") - upvoters = relationship("User", secondary=PostUpvote, back_populates='upvoted_posts') + comments = relationship("Comment", back_populates="parent_post", lazy='selectin') + upvoters = relationship("User", secondary=PostUpvote, back_populates='upvoted_posts', lazy='selectin') + + async def comment_count(self): + async with db as session: + return (await session.execute(select(func.count('*')).select_from(Comment).where(Comment.parent_post == self))).scalar() def topic_or_user(self) -> Guild | User: return self.guild or self.author @@ -489,33 +572,41 @@ class Post(Base): def generate_slug(self) -> str: return "slugify.slugify(self.title, max_length=64)" - def upvotes(self) -> int: - return (db.session.execute(select(func.count('*')).select_from(PostUpvote).where(PostUpvote.c.post_id == self.id, PostUpvote.c.is_downvote == False)).scalar() - - db.session.execute(select(func.count('*')).select_from(PostUpvote).where(PostUpvote.c.post_id == self.id, PostUpvote.c.is_downvote == True)).scalar()) + async def upvotes(self) -> int: + async with db as session: + upv = (await session.execute(select(func.count('*')).select_from(PostUpvote).where(PostUpvote.c.post_id == self.id, PostUpvote.c.is_downvote == False))).scalar() + dwv = (await session.execute(select(func.count('*')).select_from(PostUpvote).where(PostUpvote.c.post_id == self.id, PostUpvote.c.is_downvote == True))).scalar() + return upv - dwv - def upvoted_by(self, user: User | AnonymousUserMixin | None): - if not user or not user.is_authenticated: + async def upvoted_by(self, user: User | None): + if not want_User(user, var_name='user', prefix='Post.upvoted_by()'): return 0 - v: PostUpvote | None = db.session.execute(select(PostUpvote.c).where(PostUpvote.c.voter_id == user.id, PostUpvote.c.post_id == self.id)).fetchone() - if v: - if v.is_downvote: + async with db as session: + v = (await session.execute(select(PostUpvote.c.is_downvote).where(PostUpvote.c.voter_id == user.id, PostUpvote.c.post_id == self.id))).fetchone() + if v is None: + return 0 + if v == (True,): return -1 - return 1 - return 0 + if v == (False,): + return 1 + logger.warning(f'unexpected value: {v}') + return 0 - def top_level_comments(self, limit=None): - return db.session.execute(select(Comment).where(Comment.parent_comment == None, Comment.parent_post == self).order_by(Comment.created_at.desc()).limit(limit)).scalars() + async def top_level_comments(self, limit=None): + async with db as session: + return (await session.execute(select(Comment).where(Comment.parent_comment == None, Comment.parent_post == self).order_by(Comment.created_at.desc()).limit(limit))).scalars() def report_url(self) -> str: return f'/report/post/{Snowflake(self.id):l}' - def report_count(self) -> int: - return db.session.execute(select(func.count('*')).select_from(PostReport).where(PostReport.target_id == self.id, ~PostReport.update_status.in_((1, 2)))).scalar() + async def report_count(self) -> int: + async with db as session: return (await session.execute(select(func.count('*')).select_from(PostReport).where(PostReport.target_id == self.id, ~PostReport.update_status.in_((1, 2))))).scalar() @classmethod - @timed_cache(1800) - def count(cls): - return db.session.execute(select(func.count('*')).select_from(cls)).scalar() + @timed_cache(1800, async_=True) + async def count(cls): + async with db as session: + return (await session.execute(select(func.count('*')).select_from(cls))).scalar() @property def is_removed(self) -> bool: @@ -527,7 +618,8 @@ class Post(Base): @classmethod def visible_by(cls, user_id: int | None): - return or_(Post.author_id == user_id, Post.privacy.in_((0, 1))) + return or_(Post.author_id == user_id, Post.privacy == 0) + #return or_(Post.author_id == user_id, and_(Post.privacy.in_((0, 1)), ~Post.author.has_blocked_q(user_id))) class Comment(Base): @@ -554,8 +646,8 @@ class Comment(Base): removed_by_id = Column(BigInteger, ForeignKey('freak_user.id', name='user_banner_id'), nullable=True) removed_reason = Column(SmallInteger, nullable=True) - author = relationship('User', foreign_keys=[author_id])#, back_populates='comments') - parent_post: Relationship[Post] = relationship("Post", back_populates="comments", foreign_keys=[parent_post_id]) + author = relationship('User', foreign_keys=[author_id], lazy='selectin')#, back_populates='comments') + parent_post: Relationship[Post] = relationship("Post", back_populates="comments", foreign_keys=[parent_post_id], lazy='selectin') parent_comment, child_comments = parent_children('comment', parent_remote_side=Wanted('id')) def url(self): @@ -564,8 +656,9 @@ class Comment(Base): def report_url(self) -> str: return f'/report/comment/{Snowflake(self.id):l}' - def report_count(self) -> int: - return db.session.execute(select(func.count('*')).select_from(PostReport).where(PostReport.target_id == self.id, ~PostReport.update_status.in_((1, 2)))).scalar() + async def report_count(self) -> int: + async with db as session: + return (await session.execute(select(func.count('*')).select_from(PostReport).where(PostReport.target_id == self.id, ~PostReport.update_status.in_((1, 2))))).scalar() @property def is_removed(self) -> bool: @@ -588,15 +681,16 @@ class PostReport(Base): created_at = Column(DateTime, server_default=func.current_timestamp()) created_ip = Column(String(64), default=get_remote_addr, nullable=False) - author = relationship('User') + author = relationship('User', lazy='selectin') - def target(self): - if self.target_type == REPORT_TARGET_POST: - return db.session.execute(select(Post).where(Post.id == self.target_id)).scalar() - elif self.target_type == REPORT_TARGET_COMMENT: - return db.session.execute(select(Comment).where(Comment.id == self.target_id)).scalar() - else: - return self.target_id + async def target(self): + async with db as session: + if self.target_type == REPORT_TARGET_POST: + return (await session.execute(select(Post).where(Post.id == self.target_id))).scalar() + elif self.target_type == REPORT_TARGET_COMMENT: + return (await session.execute(select(Comment).where(Comment.id == self.target_id))).scalar() + else: + return self.target_id def is_critical(self): return self.reason_code in ( @@ -616,8 +710,8 @@ class UserStrike(Base): issued_at = Column(DateTime, server_default=func.current_timestamp()) issued_by_id = Column(BigInteger, ForeignKey('freak_user.id'), nullable=True) - user = relationship(User, primaryjoin= lambda: User.id == UserStrike.user_id) - issued_by = relationship(User, primaryjoin= lambda: User.id == UserStrike.issued_by_id) + user = relationship(User, primaryjoin= lambda: User.id == UserStrike.user_id, lazy='selectin') + issued_by = relationship(User, primaryjoin= lambda: User.id == UserStrike.issued_by_id, lazy='selectin') # PostUpvote table is at the top !! diff --git a/freak/rest/__init__.py b/freak/rest/__init__.py index 0957ad5..91080ec 100644 --- a/freak/rest/__init__.py +++ b/freak/rest/__init__.py @@ -1,24 +1,41 @@ +from __future__ import annotations -from flask import Blueprint, redirect, url_for -from flask_restx import Resource +from flask import abort +from quart import Blueprint, redirect, url_for +from quart_auth import current_user, login_required +from quart_schema import QuartSchema, validate_request, validate_response from sqlalchemy import select -from suou import Snowflake -from suou.flask_sqlalchemy import require_auth +from suou import Snowflake, deprecated, not_implemented, want_isodate -from suou.flask_restx import Api +from suou.quart import add_rest from ..models import Post, User, db +from .. import UserLoader, app, app_config, __version__ as freak_version -rest_bp = Blueprint('rest', __name__, url_prefix='/v1') -rest = Api(rest_bp) +bp = Blueprint('rest', __name__, url_prefix='/v1') +rest = add_rest(app, '/v1', '/ajax') -auth_required = require_auth(User, db) +current_user: UserLoader -@rest.route('/nurupo') -class Nurupo(Resource): - def get(self): - return dict(nurupo='ga') +## TODO deprecate auth_required since it does not work +from suou.flask_sqlalchemy import require_auth +auth_required = deprecated('use login_required() and current_user instead')(require_auth(User, db)) + +@not_implemented() +async def authenticated(): + pass + +@bp.get('/nurupo') +async def get_nurupo(): + return dict(ga=-1) + +@bp.get('/health') +async def health(): + return dict( + version=freak_version, + name = app_config.app_name + ) ## TODO coverage of REST is still partial, but it's planned ## to get complete sooner or later @@ -27,34 +44,44 @@ class Nurupo(Resource): ## redirect, neither is able to get user injected. ## Auth-based REST endpoints won't be fully functional until 0.6 in most cases -@rest.route('/user/@me') -class UserInfoMe(Resource): - @auth_required(required=True) - def get(self, user: User): - return redirect(url_for('rest.UserInfo', user.id)), 302 -@rest.route('/user/') -class UserInfo(Resource): - def get(self, id: int): - ## TODO sanizize REST to make blocked users inaccessible - u: User | None = db.session.execute(select(User).where(User.id == id)).scalar() +@bp.get('/user/@me') +@login_required +async def get_user_me(): + return redirect(url_for(f'rest.user_get', current_user.id)), 302 + +@bp.get('/user/') +async def user_get(id: int): + ## TODO sanizize REST to make blocked users inaccessible + async with db as session: + u: User | None = (await session.execute(select(User).where(User.id == id))).scalar() if u is None: return dict(error='User not found'), 404 uj = dict( id = f'{Snowflake(u.id):l}', username = u.username, display_name = u.display_name, - joined_at = u.joined_at.isoformat('T'), + joined_at = want_isodate(u.joined_at), karma = u.karma, - age = u.age() + age = u.age(), + biography=u.biography, + badges = u.badges() ) - return dict(users={f'{Snowflake(id):l}': uj}) + return dict(users={f'{Snowflake(id):l}': uj}) + +@bp.get('/user/@') +async def resolve_user(username: str): + async with db as session: + uid: User | None = (await session.execute(select(User.id).select_from(User).where(User.username == username))).scalar() + if uid is None: + abort(404, 'User not found') + return redirect(url_for('rest.user_get', id=uid)), 302 -@rest.route('/post/') -class SinglePost(Resource): - def get(self, id: int): - p: Post | None = db.session.execute(select(Post).where(Post.id == id)).scalar() +@bp.get('/post/') +async def get_post(id: int): + async with db as session: + p: Post | None = (await session.execute(select(Post).where(Post.id == id))).scalar() if p is None: return dict(error='Not found'), 404 pj = dict( @@ -65,4 +92,4 @@ class SinglePost(Resource): created_at = p.created_at.isoformat('T') ) - return dict(posts={f'{Snowflake(id):l}': pj}) + return dict(posts={f'{Snowflake(id):l}': pj}) diff --git a/freak/search.py b/freak/search.py index b4b7c27..6b357be 100644 --- a/freak/search.py +++ b/freak/search.py @@ -2,12 +2,8 @@ from typing import Iterable -from flask import flash, g from sqlalchemy import Column, Select, select, or_ -from .models import Guild, User, db - - class SearchQuery: keywords: Iterable[str] @@ -27,24 +23,3 @@ class SearchQuery: return sq -def find_guild_or_user(name: str) -> str | None: - """ - Used in 404 error handler. - - Returns an URL to redirect or None for no redirect. - """ - - if hasattr(g, 'no_user'): - return None - - gu = db.session.execute(select(Guild).where(Guild.name == name)).scalar() - if gu is not None: - flash(f'There is nothing at /{name}. Luckily, a guild with name {gu.handle()} happens to exist. Next time, remember to add + before!') - return gu.url() - - user = db.session.execute(select(User).where(User.username == name)).scalar() - if user is not None: - flash(f'There is nothing at /{name}. Luckily, a user named {user.handle()} happens to exist. Next time, remember to add @ before!') - return user.url() - - return None \ No newline at end of file diff --git a/freak/templates/about.html b/freak/templates/about.html index a7d0840..90a3090 100644 --- a/freak/templates/about.html +++ b/freak/templates/about.html @@ -11,20 +11,20 @@

    Stats

      -
    • No. of posts: {{ post_count }}
    • -
    • No. of active users (posters in the last 30 days): {{ user_count }}
    • +
    • # of posts: {{ post_count }}
    • +
    • # of active users (posters in the last 30 days): {{ user_count }}

    Software versions

    • Python: {{ python_version }}
    • SQLAlchemy: {{ sa_version }}
    • -
    • Flask: {{ flask_version }}
    • +
    • Quart: {{ quart_version }}
    • {{ app_name }}: {{ app_version }}

    License

    -

    Source code is available at: https://github.com/yusurko/freak

    +

    Source code is available at: https://nekode.yusur.moe/yusur/freak

    {% if impressum %}

    Legal Contacts

    diff --git a/freak/templates/admin/admin_users.html b/freak/templates/admin/admin_users.html index 31079c4..d55d9f8 100644 --- a/freak/templates/admin/admin_users.html +++ b/freak/templates/admin/admin_users.html @@ -9,7 +9,7 @@ {%- if u.is_administrator %} (Admin) {% endif -%} - {% if u == current_user %} + {% if u == current_user.user %} (You) {% endif -%}

    diff --git a/freak/templates/base.html b/freak/templates/base.html index e747247..96a9916 100644 --- a/freak/templates/base.html +++ b/freak/templates/base.html @@ -12,7 +12,7 @@ This Service is available "AS IS", with NO WARRANTY, explicit or implied. Sakuragasaki46 is NOT legally liable for Your use of the Service. This service is age-restricted; do not access if underage. - More info: https://{{ domain_name }}/terms + More info: https://{{ server_name }}/terms --> @@ -25,7 +25,7 @@ - +

    {{ app_name }}

    @@ -44,9 +44,9 @@ {% endif %} {% if g.no_user %} - {% elif current_user.is_authenticated %} + {% elif current_user %}
  • - + {{ icon('add') }} New post @@ -81,6 +81,7 @@ {% for message in get_flashed_messages() %}
    {{ message }}
    {% endfor %} + {% block body %}
    {% block heading %}{% endblock %} @@ -104,7 +105,7 @@
  • GitHub
- {% if current_user and current_user.is_authenticated %} + {% if current_user %}