From c1c005cc4e94036ba5880d274b5f511d041a4bfa Mon Sep 17 00:00:00 2001 From: Mattia Succurro Date: Thu, 19 Jun 2025 01:29:40 +0200 Subject: [PATCH] add dependency on libsuou, add settings page, color themes, strikes, blocks and memberships (the latter two to be implemented later) --- CHANGELOG.md | 7 + alembic/versions/29a8d663c7ce_.py | 92 +++++++ alembic/versions/7122c8715ff9_.py | 28 +++ alembic/versions/90c7d0098efe_.py | 32 +++ freak/__init__.py | 64 +++-- freak/ajax.py | 21 +- freak/algorithms.py | 19 +- freak/cli.py | 6 +- freak/colors.py | 39 +++ freak/dei.py | 77 ++++++ freak/filters.py | 64 +---- freak/iding.py | 15 +- freak/models.py | 228 +++++++++++++----- freak/rest/__init__.py | 16 +- freak/static/js/lib.js | 39 ++- freak/static/sass/base.sass | 68 +++++- freak/static/sass/layout.sass | 46 +++- freak/static/sass/mobile.sass | 17 +- freak/templates/admin/admin_base.html | 8 +- freak/templates/admin/admin_home.html | 7 +- .../templates/admin/admin_report_detail.html | 11 + freak/templates/admin/admin_strikes.html | 21 ++ freak/templates/base.html | 39 ++- freak/templates/feed.html | 2 +- freak/templates/macros/colors.html | 0 freak/templates/macros/embed.html | 4 +- freak/templates/macros/feed.html | 4 +- freak/templates/macros/nav.html | 6 +- freak/templates/register.html | 2 +- freak/templates/reports/report_base.html | 2 +- freak/templates/rules.html | 4 +- freak/templates/singlepost.html | 8 +- freak/templates/userfeed.html | 9 +- freak/templates/usersettings.html | 52 ++++ freak/website/accounts.py | 31 ++- freak/website/admin.py | 89 ++++++- freak/website/create.py | 29 ++- freak/website/detail.py | 32 +-- freak/website/frontpage.py | 12 +- pyproject.toml | 2 +- 40 files changed, 992 insertions(+), 260 deletions(-) create mode 100644 alembic/versions/29a8d663c7ce_.py create mode 100644 alembic/versions/7122c8715ff9_.py create mode 100644 alembic/versions/90c7d0098efe_.py create mode 100644 freak/colors.py create mode 100644 freak/dei.py create mode 100644 freak/templates/admin/admin_strikes.html create mode 100644 freak/templates/macros/colors.html create mode 100644 freak/templates/usersettings.html diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e45a91..a15077f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## 0.4.0 + +- Added dependency to [SUOU](https://github.com/sakuragasaki46/suou) +- Added user strikes, memberships and user blocks +- Added ✨color themes✨ +- Users can now set their display name and biography in `/settings` + ## 0.3.3 - Fixed bugs in templates introduced in 0.3.2 diff --git a/alembic/versions/29a8d663c7ce_.py b/alembic/versions/29a8d663c7ce_.py new file mode 100644 index 0000000..f9e7cae --- /dev/null +++ b/alembic/versions/29a8d663c7ce_.py @@ -0,0 +1,92 @@ +"""upgrade to 0.4.0 + +NOTICE: REVISIONS BEFORE 0.3.1 ARE LOST FOR GOOD + +get over it and move on: the recommended way to upgrade is via +python3 -m freak -U + +Revision ID: 29a8d663c7ce +Revises: +Create Date: 2025-06-17 21:55:16.145111 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '29a8d663c7ce' +down_revision: Union[str, None] = '7122c8715ff9' +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.create_table('freak_user_block', + sa.Column('actor_id', sa.BigInteger(), nullable=False), + sa.Column('target_id', sa.BigInteger(), nullable=False), + sa.ForeignKeyConstraint(['actor_id'], ['freak_user.id'], ), + sa.ForeignKeyConstraint(['target_id'], ['freak_user.id'], ), + sa.PrimaryKeyConstraint('actor_id', 'target_id') + ) + op.create_table('freak_user_strike', + sa.Column('id', sa.LargeBinary(length=16), nullable=False), + sa.Column('user_id', sa.BigInteger(), nullable=False), + sa.Column('target_type', sa.SmallInteger(), nullable=False), + sa.Column('target_id', sa.BigInteger(), nullable=False), + sa.Column('target_content', sa.String(length=4096), nullable=True), + sa.Column('reason_code', sa.SmallInteger(), nullable=False), + sa.Column('issued_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=True), + sa.Column('issued_by_id', sa.BigInteger(), nullable=True), + sa.ForeignKeyConstraint(['issued_by_id'], ['freak_user.id'], ), + sa.ForeignKeyConstraint(['user_id'], ['freak_user.id'], ondelete='cascade'), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('freak_member', + sa.Column('id', sa.LargeBinary(length=16), nullable=False), + sa.Column('user_id', sa.BigInteger(), nullable=True), + sa.Column('guild_id', sa.BigInteger(), nullable=True), + sa.Column('is_approved', sa.Boolean(), server_default=sa.text('false'), nullable=False), + sa.Column('is_subscribed', sa.Boolean(), server_default=sa.text('false'), nullable=False), + sa.Column('is_moderator', sa.Boolean(), server_default=sa.text('false'), nullable=False), + sa.Column('banned_at', sa.DateTime(), nullable=True), + sa.Column('banned_by_id', sa.BigInteger(), nullable=True), + sa.Column('banned_reason', sa.SmallInteger(), server_default=sa.text('0'), nullable=True), + sa.Column('banned_until', sa.DateTime(), nullable=True), + sa.Column('banned_message', sa.String(length=256), nullable=True), + sa.ForeignKeyConstraint(['banned_by_id'], ['freak_user.id'], name='user_banner_id'), + sa.ForeignKeyConstraint(['guild_id'], ['freak_topic.id'], ), + sa.ForeignKeyConstraint(['user_id'], ['freak_user.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('user_id', 'guild_id', name='member_user_topic') + ) + op.add_column('freak_topic', sa.Column('is_restricted', sa.Boolean(), server_default=sa.text('false'), nullable=False)) + op.add_column('freak_topic', sa.Column('is_public', sa.Boolean(), server_default=sa.text('true'), nullable=False)) + op.drop_column('freak_topic', 'privacy') + op.add_column('freak_user', sa.Column('pronouns', sa.Integer(), server_default=sa.text('0'), nullable=False)) + op.add_column('freak_user', sa.Column('biography', sa.String(length=1024), nullable=True)) + op.add_column('freak_user', sa.Column('is_approved', sa.Boolean(), server_default=sa.text('false'), nullable=False)) + op.add_column('freak_user', sa.Column('invited_by_id', sa.BigInteger(), nullable=True)) + op.create_foreign_key('user_inviter_id', 'freak_user', 'freak_user', ['invited_by_id'], ['id']) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint('user_inviter_id', 'freak_user', type_='foreignkey') + op.drop_column('freak_user', 'invited_by_id') + op.drop_column('freak_user', 'is_approved') + op.drop_column('freak_user', 'biography') + op.drop_column('freak_user', 'pronouns') + op.add_column('freak_topic', sa.Column('privacy', sa.SMALLINT(), server_default=sa.text('0'), autoincrement=False, nullable=True)) + op.drop_column('freak_topic', 'is_public') + op.drop_column('freak_topic', 'is_restricted') + op.drop_table('freak_member') + op.drop_table('freak_user_strike') + op.drop_table('freak_user_block') + # ### end Alembic commands ### diff --git a/alembic/versions/7122c8715ff9_.py b/alembic/versions/7122c8715ff9_.py new file mode 100644 index 0000000..67eb85a --- /dev/null +++ b/alembic/versions/7122c8715ff9_.py @@ -0,0 +1,28 @@ +"""autogenerated to allow downgrade to nothing as a bugfix + +Revision ID: 7122c8715ff9 +Revises: 29a8d663c7ce +Create Date: 2025-06-17 22:05:14.803669 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '7122c8715ff9' +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.""" + pass + + +def downgrade() -> None: + """Downgrade schema.""" + pass diff --git a/alembic/versions/90c7d0098efe_.py b/alembic/versions/90c7d0098efe_.py new file mode 100644 index 0000000..07390d9 --- /dev/null +++ b/alembic/versions/90c7d0098efe_.py @@ -0,0 +1,32 @@ +"""empty message + +Revision ID: 90c7d0098efe +Revises: 29a8d663c7ce +Create Date: 2025-06-19 01:16:41.120290 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '90c7d0098efe' +down_revision: Union[str, None] = '29a8d663c7ce' +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_user', sa.Column('color_theme', sa.SmallInteger(), server_default=sa.text('0'), nullable=False)) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('freak_user', 'color_theme') + # ### end Alembic commands ### diff --git a/freak/__init__.py b/freak/__init__.py index 4fc82b0..44416e0 100644 --- a/freak/__init__.py +++ b/freak/__init__.py @@ -1,9 +1,11 @@ +import re from sqlite3 import ProgrammingError +from typing import Any import warnings from flask import ( - Flask, g, redirect, render_template, + Flask, g, render_template, request, send_from_directory, url_for ) import os @@ -11,23 +13,38 @@ import dotenv from flask_login import LoginManager from flask_wtf.csrf import CSRFProtect from sqlalchemy import select +from sqlalchemy.exc import SQLAlchemyError +from suou import Snowflake, ssv_list from werkzeug.routing import BaseConverter from sassutils.wsgi import SassMiddleware -__version__ = '0.3.3' +from suou.configparse import ConfigOptions, ConfigValue + +from freak.colors import color_themes, theme_classes + +__version__ = '0.4.0-dev24' APP_BASE_DIR = os.path.dirname(os.path.dirname(__file__)) if not dotenv.load_dotenv(): - warnings.warn('.env not loaded; application may break!', UserWarning) + warnings.warn('.env not loaded; application may break!', RuntimeWarning) + +class AppConfig(ConfigOptions): + secret_key = ConfigValue(required=True) + database_url = ConfigValue(required=True) + app_name = ConfigValue() + 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_config = AppConfig() app = Flask(__name__) -app.secret_key = os.getenv('SECRET_KEY') -app.config['SQLALCHEMY_DATABASE_URI'] = os.getenv('DATABASE_URL') +app.secret_key = app_config.secret_key +app.config['SQLALCHEMY_DATABASE_URI'] = app_config.database_url app.config['JSONIFY_PRETTYPRINT_REGULAR'] = False from .models import db, User, Post -from .iding import id_from_b32l, id_to_b32l # SASS app.wsgi_app = SassMiddleware(app.wsgi_app, dict( @@ -40,9 +57,9 @@ class SlugConverter(BaseConverter): class B32lConverter(BaseConverter): regex = r'_?[a-z2-7]+' def to_url(self, value): - return id_to_b32l(value) + return Snowflake(value).to_b32l() def to_python(self, value): - return id_from_b32l(value) + return Snowflake.from_b32l(value) app.url_map.converters['slug'] = SlugConverter app.url_map.converters['b32l'] = B32lConverter @@ -62,33 +79,40 @@ PRIVATE_ASSETS = os.getenv('PRIVATE_ASSETS', '').split() @app.context_processor def _inject_variables(): return { - 'app_name': os.getenv('APP_NAME'), + 'app_name': app_config.app_name, 'app_version': __version__, - 'domain_name': os.getenv('DOMAIN_NAME'), + 'domain_name': app_config.domain_name, 'url_for_css': (lambda name, **kwargs: url_for('static', filename=f'css/{name}.css', **kwargs)), - 'private_scripts': [x for x in PRIVATE_ASSETS if x.endswith('.js')], + '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': os.getenv('JQUERY_URL') or 'https://ajax.googleapis.com/ajax/libs/jquery/3.7.1/jquery.min.js', + 'jquery_url': app_config.jquery_url, 'post_count': Post.count(), - 'user_count': User.active_count() + 'user_count': User.active_count(), + 'colors': color_themes, + 'theme_classes': theme_classes } @login_manager.user_loader def _inject_user(userid): try: - return db.session.execute(select(User).where(User.id == userid)).scalar() - except Exception: - warnings.warn(f'cannot retrieve user {userid} from db', RuntimeWarning) + 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) g.no_user = True return None +def redact_url_password(u: str | Any) -> str | Any: + if not isinstance(u, str): + return u + return re.sub(r':[^@:/ ]+@', ':***@', u) + @app.errorhandler(ProgrammingError) def error_db(body): g.no_user = True - warnings.warn(f'No database access! (url is {app.config['SQLALCHEMY_DATABASE_URI']})', RuntimeWarning) - fix_database_url() - if request.method in ('HEAD', 'GET') and not 'retry' in request.args: - return redirect(request.url + ('&' if '?' in request.url else '?') + 'retry=1'), 307, {'cache-control': 'private,no-cache,must-revalidate,max-age=0'} + warnings.warn(f'No database access! (url is {redact_url_password(app.config['SQLALCHEMY_DATABASE_URI'])!r})', RuntimeWarning) return render_template('500.html'), 500 @app.errorhandler(400) diff --git a/freak/ajax.py b/freak/ajax.py index 83a4185..82abb9d 100644 --- a/freak/ajax.py +++ b/freak/ajax.py @@ -7,7 +7,8 @@ AJAX hooks for the website. import re from flask import Blueprint, request -from .models import Topic, db, User, Post, PostUpvote +from sqlalchemy import delete, insert, select +from .models import Guild, db, User, Post, PostUpvote from flask_login import current_user, login_required bp = Blueprint('ajax', __name__) @@ -18,7 +19,7 @@ def username_availability(username: str): is_valid = re.fullmatch('[a-z0-9_-]+', username) is not None if is_valid: - user = db.session.execute(db.select(User).where(User.username == username)).scalar() + user = db.session.execute(select(User).where(User.username == username)).scalar() is_available = user is None or user == current_user else: @@ -32,10 +33,10 @@ def username_availability(username: str): @bp.route('/guild_name_availability/') def guild_name_availability(name: str): - is_valid = re.fullmatch('[a-z0-9_-]+', username) is not None + is_valid = re.fullmatch('[a-z0-9_-]+', name) is not None if is_valid: - gd = db.session.execute(db.select(Topic).where(Topic.name == name)).scalar() + gd = db.session.execute(select(Guild).where(Guild.name == name)).scalar() is_available = gd is None else: @@ -51,19 +52,19 @@ def guild_name_availability(name: str): @login_required def post_upvote(id): o = request.form['o'] - p: Post | None = db.session.execute(db.select(Post).where(Post.id == id)).scalar() + p: Post | None = db.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(db.delete(PostUpvote).where(PostUpvote.c.post_id == p.id, PostUpvote.c.voter_id == current_user.id, PostUpvote.c.is_downvote == True)) - db.session.execute(db.insert(PostUpvote).values(post_id = p.id, voter_id = current_user.id, is_downvote = False)) + 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(db.delete(PostUpvote).where(PostUpvote.c.post_id == p.id, PostUpvote.c.voter_id == current_user.id)) + 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(db.delete(PostUpvote).where(PostUpvote.c.post_id == p.id, PostUpvote.c.voter_id == current_user.id, PostUpvote.c.is_downvote == False)) - db.session.execute(db.insert(PostUpvote).values(post_id = p.id, voter_id = current_user.id, is_downvote = True)) + 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 diff --git a/freak/algorithms.py b/freak/algorithms.py index efc7bf6..bd43525 100644 --- a/freak/algorithms.py +++ b/freak/algorithms.py @@ -2,30 +2,33 @@ from flask_login import current_user from sqlalchemy import func, select -from .models import db, Post, Topic, User +from .models import db, Post, Guild, User def cuser() -> User: return current_user if current_user.is_authenticated else None +def cuser_id() -> int: + return current_user.id if current_user.is_authenticated else None + def public_timeline(): return select(Post).join(User, User.id == Post.author_id).where( - Post.privacy == 0, User.not_suspended(), Post.not_removed() + Post.privacy == 0, User.not_suspended(), Post.not_removed(), User.has_not_blocked(Post.author_id, cuser_id()) ).order_by(Post.created_at.desc()) -def topic_timeline(topic_name): - return select(Post).join(Topic).join(User, User.id == Post.author_id).where( - Post.privacy == 0, Topic.name == topic_name, User.not_suspended(), Post.not_removed() +def topic_timeline(gname): + return select(Post).join(Guild).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): return select(Post).join(User, User.id == Post.author_id).where( - Post.visible_by(cuser()), User.id == user_id, User.not_suspended(), Post.not_removed() + Post.visible_by(cuser()), User.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().label('post_count') - qr = select(Topic, q_post_count)\ - .join(Post, Post.topic_id == Topic.id).group_by(Topic)\ + qr = select(Guild, q_post_count)\ + .join(Post, Post.topic_id == Guild.id).group_by(Guild)\ .having(q_post_count > 5).order_by(q_post_count.desc()) return qr diff --git a/freak/cli.py b/freak/cli.py index 4554a40..34a1959 100644 --- a/freak/cli.py +++ b/freak/cli.py @@ -21,8 +21,12 @@ def main(): engine = create_engine(os.getenv('DATABASE_URL')) if args.upgrade: + ret_code = subprocess.Popen(['alembic', 'upgrade', 'head']).wait() + if ret_code != 0: + print(f'Schema upgrade failed (code: {ret_code})') + exit(ret_code) + # if the alembic/versions folder is empty db.metadata.create_all(engine) - subprocess.Popen(['alembic', 'upgrade', 'head']).wait() print('Schema upgraded!') if args.flush: diff --git a/freak/colors.py b/freak/colors.py new file mode 100644 index 0000000..2391f87 --- /dev/null +++ b/freak/colors.py @@ -0,0 +1,39 @@ + + +from collections import namedtuple + + +ColorTheme = namedtuple('ColorTheme', 'code name') + +## actual color codes are set in CSS + +color_themes = [ + ColorTheme(0, 'Default'), + ColorTheme(1, 'Rei'), + ColorTheme(2, 'Ai'), + ColorTheme(3, 'Aqua'), + ColorTheme(4, 'Neru'), + ColorTheme(5, 'Gumi'), + ColorTheme(6, 'Emu'), + ColorTheme(7, 'Spacegray'), + ColorTheme(8, 'Haku'), + ColorTheme(9, 'Miku'), + ColorTheme(10, 'Defoko'), + ColorTheme(11, 'Kaito'), + ColorTheme(12, 'Meiko'), + ColorTheme(13, 'Leek'), + ColorTheme(14, 'Teto'), + ColorTheme(15, 'Ruby') +] + +def theme_classes(color_code: int): + cl = [] + sch, th = divmod(color_code, 256) + if sch == 1: + cl.append('color-scheme-light') + if sch == 2: + cl.append('color-scheme-dark') + if 1 <= th <= 15: + cl.append(f'color-theme-{th}') + + return ' '.join(cl) diff --git a/freak/dei.py b/freak/dei.py new file mode 100644 index 0000000..8ddebb4 --- /dev/null +++ b/freak/dei.py @@ -0,0 +1,77 @@ +""" +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/filters.py b/freak/filters.py index 4c1d0be..378cfc5 100644 --- a/freak/filters.py +++ b/freak/filters.py @@ -1,78 +1,36 @@ -import re, markdown -from markdown.inlinepatterns import InlineProcessor, SimpleTagInlineProcessor -import xml.etree.ElementTree as etree +import markdown from markupsafe import Markup +from suou import Snowflake +from suou.markdown import StrikethroughExtension, SpoilerExtension, PingExtension from . import app -from .iding import id_to_b32l - -#### MARKDOWN EXTENSIONS #### - -class StrikethroughExtension(markdown.extensions.Extension): - def extendMarkdown(self, md: markdown.Markdown, md_globals=None): - postprocessor = StrikethroughPostprocessor(md) - md.postprocessors.register(postprocessor, 'strikethrough', 0) - -class StrikethroughPostprocessor(markdown.postprocessors.Postprocessor): - pattern = re.compile(r"~~(((?!~~).)+)~~", re.DOTALL) - - def run(self, html): - return re.sub(self.pattern, self.convert, html) - - def convert(self, match): - return '' + match.group(1) + '' - - -### XXX it currently only detects spoilers that are not at the beginning of the line. To be fixed. -class SpoilerExtension(markdown.extensions.Extension): - def extendMarkdown(self, md: markdown.Markdown, md_globals=None): - md.inlinePatterns.register(SimpleTagInlineProcessor(r'()>!(.*?)!<', 'span class="spoiler"'), 'spoiler', 14) - - @classmethod - def patch_blockquote_processor(cls): - """Patch BlockquoteProcessor to make Spoiler prevail over blockquotes.""" - from markdown.blockprocessors import BlockQuoteProcessor - BlockQuoteProcessor.RE = re.compile(r'(^|\n)[ ]{0,3}>(?!!)[ ]?(.*)') - -# make spoilers prevail over blockquotes +# make spoilers prevail over blockquotes SpoilerExtension.patch_blockquote_processor() -class MentionPattern(InlineProcessor): - def __init__(self, regex, url_prefix: str): - super().__init__(regex) - self.url_prefix = url_prefix - def handleMatch(self, m, data): - el = etree.Element('a') - el.attrib['href'] = self.url_prefix + m.group(1) - el.text = m.group(0) - return el, m.start(0), m.end(0) - -class PingExtension(markdown.extensions.Extension): - def extendMarkdown(self, md: markdown.Markdown, md_globals=None): - md.inlinePatterns.register(MentionPattern(r'@([a-zA-Z0-9_-]{2,32})', '/@'), 'ping_mention', 14) - md.inlinePatterns.register(MentionPattern(r'\+([a-zA-Z0-9_-]{2,32})', '/+'), 'ping_mention', 14) - @app.template_filter() def to_markdown(text, toc = False): extensions = [ 'tables', 'footnotes', 'fenced_code', 'sane_lists', StrikethroughExtension(), SpoilerExtension(), - ## XXX untested - PingExtension() + PingExtension({'@': '/@', '+': '/+'}) ] if toc: extensions.append('toc') return Markup(markdown.Markdown(extensions=extensions).convert(text)) +app.template_filter('markdown')(to_markdown) + @app.template_filter() def to_b32l(n): - return id_to_b32l(n) + return Snowflake(n).to_b32l() +app.template_filter('b32l')(to_b32l) @app.template_filter() -def append(text, l): +def append(text, l: list): l.append(text) return None + diff --git a/freak/iding.py b/freak/iding.py index b028f53..3295393 100644 --- a/freak/iding.py +++ b/freak/iding.py @@ -1,17 +1,24 @@ """ +DEPRECATED use suou.snowflake instead. + PSA: this module is for the LEGACY (v2) iding. -For the SIQ-based ID's (upcoming 0.4), see suou.iding +For the SIQ-based ID's, see suou.iding . + +The suou library also provides snowflake support. """ import base64 import os import time +from suou.functools import deprecated + epoch = 1577833200000 machine_id = int(os.getenv("MACHINE_ID", "0")) machine_counter = 0 +@deprecated('use SnowflakeGen(). Planned for removal in 0.5') def new_id(*, from_date = None): global machine_counter @@ -28,14 +35,16 @@ def new_id(*, from_date = None): ((machine_counter := machine_counter + 1) % 1024) ) -def id_to_b32l(n): +@deprecated('use suou.Snowflake.to_b32l() instead') +def id_to_b32l(n: int) -> str: return ( '_' if n < 0 else '' ) + base64.b32encode( (-n if n < 0 else n).to_bytes(10, 'big') ).decode().lstrip('A').lower() -def id_from_b32l(s, *, n_bytes=10): +@deprecated('use suou.Snowflake.from_b32l() instead') +def id_from_b32l(s: str) -> int: return (-1 if s.startswith('_') else 1) * int.from_bytes( base64.b32decode(s.lstrip('_').upper().rjust(16, 'A').encode()), 'big' ) diff --git a/freak/models.py b/freak/models.py index e617b9e..3f0b480 100644 --- a/freak/models.py +++ b/freak/models.py @@ -4,18 +4,20 @@ from __future__ import annotations from collections import namedtuple import datetime -from functools import lru_cache +from functools import partial from operator import or_ from threading import Lock -from sqlalchemy import Column, String, ForeignKey, and_, text, \ +from sqlalchemy import Column, Integer, String, ForeignKey, UniqueConstraint, text, \ CheckConstraint, Date, DateTime, Boolean, func, BigInteger, \ - SmallInteger, select, insert, update, create_engine, Table -from sqlalchemy.orm import Relationship, declarative_base, relationship + 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 import create_session, declarative_base, id_column, parent_children, snowflake_column from werkzeug.security import check_password_hash -import os -from .iding import new_id, id_to_b32l + +from freak import app_config from .utils import age_and_days, get_remote_addr, timed_cache @@ -25,23 +27,27 @@ USER_ACTIVE = 0 USER_INACTIVE = 1 USER_BANNED = 2 -ReportReason = namedtuple('ReportReason', 'num_code code description') +ReportReason = namedtuple('ReportReason', 'num_code code description extra', defaults=dict(extra=None)) post_report_reasons = [ + ## emergency ReportReason(110, 'hate_speech', 'Extreme hate speech / terrorism'), - ReportReason(121, 'csam', 'Child abuse or endangerment'), + ReportReason(121, 'csam', 'Child abuse or endangerment', extra=dict(suspend=True)), ReportReason(142, 'revenge_sxm', 'Revenge porn'), ReportReason(122, 'black_market', 'Sale or promotion of regulated goods (e.g. firearms)'), + ## urgent ReportReason(171, 'xxx', 'Pornography'), ReportReason(111, 'tasteless', 'Extreme violence / gore'), ReportReason(180, 'impersonation', 'Impersonation'), ReportReason(141, 'doxing', 'Diffusion of PII (personally identifiable information)'), - ReportReason(123, 'copyviol', 'This is my creation and someone else is using it without my permission/license'), + ## less urgent ReportReason(140, 'bullying', 'Harassment, bullying, or suicide incitement'), ReportReason(112, 'lgbt_hate', 'Hate speech against LGBTQ+ or women'), ReportReason(150, 'security_exploit', 'Dangerous security exploit or violation'), ReportReason(190, 'false_information', 'False or deceiving information'), - ReportReason(210, 'underage', 'Presence in violation of age limits (i.e. under 13, or minor in adult spaces)') + ReportReason(123, 'copyviol', 'This is my creation and someone else is using it without my permission/license'), + ## minor (unironically) + ReportReason(210, 'underage', 'Presence in violation of age limits (i.e. under 13, or minor in adult spaces)', extra=dict(suspend=True)) ] REPORT_REASON_STRINGS = { **{x.num_code: x.description for x in post_report_reasons}, **{x.code: x.description for x in post_report_reasons} } @@ -58,20 +64,18 @@ REPORT_UPDATE_ON_HOLD = 3 ## END constants and enums -Base = declarative_base() +Base = declarative_base(app_config.domain_name, app_config.secret_key, + snowflake_epoch=1577833200) db = SQLAlchemy(model_class=Base) -def create_session_interactively(): - '''Create a session for querying the database in Python REPL.''' - engine = create_engine(os.getenv('DATABASE_URL')) - return db.Session(bind = engine) +CSI = create_session_interactively = partial(create_session, app_config.database_url) -CSI = create_session_interactively -## TODO replace with suou.declarative_base() - upcoming 0.4 +# 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 @@ -86,10 +90,22 @@ PostUpvote = Table( Column('is_downvote', Boolean, server_default=text('false')) ) -class User(BaseModel): - __tablename__ = 'freak_user' +UserBlock = Table( + 'freak_user_block', + Base.metadata, + Column('actor_id', BigInteger, ForeignKey('freak_user.id'), primary_key=True), + Column('target_id', BigInteger, ForeignKey('freak_user.id'), primary_key=True) +) - id = Column(BigInteger, primary_key=True, default=new_id, unique=True) + +class User(Base): + __tablename__ = 'freak_user' + __table_args__ = ( + ## XXX this constraint (and the other three at Post, Guild and Comment) cannot be removed!! + UniqueConstraint('id', name='user_id_uniq'), + ) + + id = snowflake_column() username = Column(String(32), CheckConstraint(text("username = lower(username) and username ~ '^[a-z0-9_-]+$'"), name="user_username_valid"), unique=True, nullable=False) display_name = Column(String(64), nullable=False) @@ -102,7 +118,10 @@ class User(BaseModel): 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) + + 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')) # moderation banned_at = Column(DateTime, nullable=True) @@ -110,18 +129,22 @@ class User(BaseModel): banned_reason = Column(SmallInteger, server_default=text('0'), nullable=True) banned_until = Column(DateTime, nullable=True) banned_message = Column(String(256), nullable=True) + + # invites + is_approved = Column(Boolean, server_default=text('false'), nullable=False) + invited_by_id = Column(BigInteger, ForeignKey('freak_user.id', name='user_inviter_id'), nullable=True) # utilities - #posts = relationship("Post", back_populates='author', ) - upvoted_posts = relationship("Post", secondary=PostUpvote, back_populates='upvoters') - #comments = relationship("Comment", back_populates='author') ## 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', ) + upvoted_posts = relationship("Post", secondary=PostUpvote, back_populates='upvoters') + #comments = relationship("Comment", back_populates='author') + @property def is_disabled(self): - return self.banned_at is not None or self.is_disabled_by_user + 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 @property def is_active(self): @@ -151,7 +174,7 @@ class User(BaseModel): """ ## XXX change func name? return dict( - id = id_to_b32l(self.id), + id = Snowflake(self.id).to_b32l(), username = self.username, display_name = self.display_name, age = self.age() @@ -159,15 +182,18 @@ class User(BaseModel): ) 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() def can_create_guild(self): + ## TODO make guild creation requirements configurable return self.karma > 15 or self.is_administrator - ## deprecated alias! - can_create_community = can_create_guild + can_create_community = deprecated('use .can_create_guild()')(can_create_guild) def handle(self): return f'@{self.username}' @@ -188,6 +214,22 @@ class User(BaseModel): def not_suspended(cls): return or_(User.banned_at == None, User.banned_until <= datetime.datetime.now()) + @classmethod + def has_not_blocked(cls, actor, target): + """ + Filter out a content if the author has blocked current user. + + XXX untested. + """ + + # TODO add recognition + actor_id = actor + 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): c = 0 c += db.session.execute(select(func.count('*')).select_from(Post).where(Post.author == self)).scalar() @@ -196,10 +238,19 @@ class User(BaseModel): self.karma = c -class Topic(BaseModel): - __tablename__ = 'freak_topic' + @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() - id = Column(BigInteger, primary_key=True, default=new_id, unique=True) +## END User + +class Guild(Base): + __tablename__ = 'freak_topic' + __table_args__ = ( + UniqueConstraint('id', name='topic_id_uniq'), + ) + + id = snowflake_column() name = Column(String(32), CheckConstraint(text("name = lower(name) AND name ~ '^[a-z0-9_-]+$'"), name='topic_name_valid'), unique=True, nullable=False) display_name = Column(String(64), nullable=False) @@ -207,8 +258,12 @@ class Topic(BaseModel): created_at = Column(DateTime, server_default=func.current_timestamp(), index=True, nullable=False) owner_id = Column(BigInteger, ForeignKey('freak_user.id', name='topic_owner_id'), nullable=True) language = Column(String(16), server_default=text("'en-US'")) - privacy = Column(SmallInteger, server_default=text('0')) + # true: prevent non-members from participating + is_restricted = Column(Boolean, server_default=text('false'), nullable=False) + # false: make the guild invite-only + is_public = Column(Boolean, server_default=text('true'), nullable=False) + # MUST NOT be filled in on post-0.2 instances legacy_id = Column(BigInteger, nullable=True) def url(self): @@ -218,16 +273,56 @@ class Topic(BaseModel): return f'+{self.name}' # utilities - posts = relationship('Post', back_populates='topic') + posts = relationship('Post', back_populates='guild') + + +Topic = deprecated('renamed to Guild')(Guild) + +## END Guild + +class Member(Base): + """ + User-Guild relationship. NEW in 0.4.0. + """ + __tablename__ = 'freak_member' + __table_args__ = ( + UniqueConstraint('user_id', 'guild_id', name='member_user_topic'), + ) + + ## Newer tables use SIQ. Older tables will gradually transition to SIQ as well. + id = id_column(SiqType.MANYTOMANY) + user_id = Column(BigInteger, ForeignKey('freak_user.id')) + guild_id = Column(BigInteger, ForeignKey('freak_topic.id')) + is_approved = Column(Boolean, server_default=text('false'), nullable=False) + is_subscribed = Column(Boolean, server_default=text('false'), nullable=False) + is_moderator = Column(Boolean, server_default=text('false'), nullable=False) + + # moderation + banned_at = Column(DateTime, nullable=True) + banned_by_id = Column(BigInteger, ForeignKey('freak_user.id', name='user_banner_id'), nullable=True) + banned_reason = Column(SmallInteger, server_default=text('0'), nullable=True) + 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) + + @property + def is_banned(self): + return self.banned_at is not None and (self.banned_until is None or self.banned_until <= datetime.datetime.now()) POST_TYPE_DEFAULT = 0 POST_TYPE_LINK = 1 -class Post(BaseModel): +class Post(Base): __tablename__ = 'freak_post' + __table_args__ = ( + UniqueConstraint('id', name='post_id_uniq'), + ) - id = Column(BigInteger, primary_key=True, default=new_id, unique=True) + id = snowflake_column() slug = Column(String(64), CheckConstraint("slug IS NULL OR (slug = lower(slug) AND slug ~ '^[a-z0-9_-]+$')", name='post_slug_valid'), nullable=True) title = Column(String(256), nullable=False) @@ -251,16 +346,17 @@ class Post(BaseModel): # utilities author: Relationship[User] = relationship("User", lazy='selectin', foreign_keys=[author_id])#, back_populates="posts") - topic = relationship("Topic", back_populates="posts", lazy='selectin') + guild = relationship("Guild", back_populates="posts", lazy='selectin') comments = relationship("Comment", back_populates="parent_post") upvoters = relationship("User", secondary=PostUpvote, back_populates='upvoted_posts') - def topic_or_user(self) -> Topic | User: - return self.topic or self.author + def topic_or_user(self) -> Guild | User: + return self.guild or self.author def url(self): - return self.topic_or_user().url() + '/comments/' + id_to_b32l(self.id) + '/' + (self.slug or '') + return self.topic_or_user().url() + '/comments/' + Snowflake(self.id).to_b32l() + '/' + (self.slug or '') + @not_implemented def generate_slug(self): return slugify.slugify(self.title, max_length=64) @@ -271,7 +367,7 @@ class Post(BaseModel): def upvoted_by(self, user: User | AnonymousUserMixin | None): if not user or not user.is_authenticated: return 0 - v = db.session.execute(db.select(PostUpvote.c).where(PostUpvote.c.voter_id == user.id, PostUpvote.c.post_id == self.id)).fetchone() + 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: return -1 @@ -282,7 +378,7 @@ class Post(BaseModel): return db.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 '/report/post/' + id_to_b32l(self.id) + 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() @@ -305,12 +401,13 @@ class Post(BaseModel): return or_(Post.author_id == user.id, Post.privacy.in_((0, 1))) -class Comment(BaseModel): +class Comment(Base): __tablename__ = 'freak_comment' + __table_args__ = ( + UniqueConstraint('id', name='comment_id_uniq'), + ) - # tweak to allow remote_side to work - ## XXX will be changed in 0.4 to suou.id_column() - id = Column(BigInteger, primary_key=True, default=new_id, unique=True) + 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) @@ -321,6 +418,7 @@ class Comment(BaseModel): updated_at = Column(DateTime, nullable=True) is_locked = Column(Boolean, server_default=text('false')) + ## DO NOT FILL IN! intended for 0.2 or earlier legacy_id = Column(BigInteger, nullable=True) removed_at = Column(DateTime, nullable=True) @@ -328,15 +426,14 @@ class Comment(BaseModel): removed_reason = Column(SmallInteger, nullable=True) author = relationship('User', foreign_keys=[author_id])#, back_populates='comments') - parent_post = relationship("Post", back_populates="comments", foreign_keys=[parent_post_id]) - parent_comment = relationship("Comment", back_populates="child_comments", remote_side=[id]) - child_comments = relationship("Comment", back_populates="parent_comment") + parent_post: Relationship[Post] = relationship("Post", back_populates="comments", foreign_keys=[parent_post_id]) + parent_comment, child_comments = parent_children('comment', parent_remote_side=Wanted('id')) def url(self): - return self.parent_post.url() + '/comment/' + id_to_b32l(self.id) + return self.parent_post.url() + f'/comment/{Snowflake(self.id):l}' def report_url(self) -> str: - return '/report/comment/' + id_to_b32l(self.id) + 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() @@ -349,8 +446,10 @@ class Comment(BaseModel): def not_removed(cls): return Post.removed_at == None -class PostReport(BaseModel): +class PostReport(Base): __tablename__ = 'freak_postreport' + + id = snowflake_column() author_id = Column(BigInteger, ForeignKey('freak_user.id', name='report_author_id'), nullable=True) target_type = Column(SmallInteger, nullable=False) @@ -361,7 +460,7 @@ class PostReport(BaseModel): created_ip = Column(String(64), default=get_remote_addr, nullable=False) author = relationship('User') - + def target(self): if self.target_type == REPORT_TARGET_POST: return db.session.execute(select(Post).where(Post.id == self.target_id)).scalar() @@ -370,6 +469,27 @@ class PostReport(BaseModel): else: return self.target_id + def is_critical(self): + return self.reason_code in ( + 121, 142, 210 + ) + +class UserStrike(Base): + __tablename__ = 'freak_user_strike' + + id = id_column(SiqType.MULTI) + + user_id = Column(BigInteger, ForeignKey('freak_user.id', ondelete='cascade'), nullable=False) + target_type = Column(SmallInteger, nullable=False) + target_id = Column(BigInteger, nullable=False) + target_content = Column(String(4096), nullable=True) + reason_code = Column(SmallInteger, nullable=False) + 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) + # PostUpvote table is at the top !! diff --git a/freak/rest/__init__.py b/freak/rest/__init__.py index 3e3013c..b203afa 100644 --- a/freak/rest/__init__.py +++ b/freak/rest/__init__.py @@ -2,8 +2,8 @@ from flask import Blueprint from flask_restx import Resource, Api - -from freak.iding import id_to_b32l +from sqlalchemy import select +from suou import Snowflake from ..models import Post, User, db @@ -21,31 +21,31 @@ class Nurupo(Resource): @rest.route('/user/') class UserInfo(Resource): def get(self, id: int): - u: User | None = db.session.execute(db.select(User).where(User.id == id)).scalar() + u: User | None = db.session.execute(select(User).where(User.id == id)).scalar() if u is None: return dict(error='User not found'), 404 uj = dict( - id = id_to_b32l(u.id), + id = f'{Snowflake(u.id):l}', username = u.username, display_name = u.display_name, joined_at = u.joined_at.isoformat('T'), karma = u.karma, age = u.age() ) - return dict(users={id_to_b32l(id): uj}) + return dict(users={f'{Snowflake(id):l}': uj}) @rest.route('/post/') class SinglePost(Resource): def get(self, id: int): - p: Post | None = db.session.execute(db.select(Post).where(Post.id == id)).scalar() + p: Post | None = db.session.execute(select(Post).where(Post.id == id)).scalar() if p is None: return dict(error='Not found'), 404 pj = dict( - id = id_to_b32l(p.id), + id = f'{Snowflake(p.id):l}', title = p.title, author = p.author.simple_info(), to = p.topic_or_user().handle(), created_at = p.created_at.isoformat('T') ) - return dict(posts={id_to_b32l(id): pj}) \ No newline at end of file + return dict(posts={f'{Snowflake(id):l}': pj}) \ No newline at end of file diff --git a/freak/static/js/lib.js b/freak/static/js/lib.js index 7bebaed..0e6f824 100644 --- a/freak/static/js/lib.js +++ b/freak/static/js/lib.js @@ -1,14 +1,6 @@ (function(){ - // UNUSED! Period is disallowed regardless now - function checkUsername(u){ - return ( - /^\./.test(u)? 'You cannot start username with a period.': - /\.$/.test(u)? 'You cannot end username with a period.': - /\.\./.test(u)? 'You cannot have more than one period in a row.': - u.match(/\.(com|net|org|txt)$/)? 'Your username cannot end with .' + forbidden_extensions[1]: - 'ok' - ); - } + "use strict"; + function attachUsernameInput(){ @@ -140,9 +132,36 @@ }).then(e => e.json()); } + function enableThemeChange() { + let schemeItems = document.querySelectorAll('.apply-theme [name="color_scheme"]'); + + for (let ii of schemeItems) { + ii.addEventListener('change', function(e) { + let removed_classes = Array.from(document.body.classList).filter((x) => /^color-scheme-/.test(x)); + document.body.classList.remove(...removed_classes); + if (e.target.value !== 'unset') { + document.body.classList.add(`color-scheme-${e.target.value}`); + } + console.log(`Color scheme changed to ${e.target.value}`) + }) + } + + let themeItems = document.querySelectorAll('.apply-theme [name="color_theme"]'); + + for (let ii of themeItems) { + ii.addEventListener('change', function(e) { + let removed_classes = Array.from(document.body.classList).filter((x) => /^color-theme-/.test(x)); + document.body.classList.remove(...removed_classes); + document.body.classList.add(`color-theme-${e.target.value}`); + console.log(`Color theme changed to ${e.target.value}`) + }) + } + } + function main() { attachUsernameInput(); enablePostVotes(); + enableThemeChange(); } main(); diff --git a/freak/static/sass/base.sass b/freak/static/sass/base.sass index 97b65a6..a45f049 100644 --- a/freak/static/sass/base.sass +++ b/freak/static/sass/base.sass @@ -5,7 +5,22 @@ box-sizing: border-box \:root - --accent: #ff7300 + --c0-accent: #ff7300 + --c1-accent: #ff7300 + --c2-accent: #f837ce + --c3-accent: #38b8ff + --c4-accent: #ffe338 + --c5-accent: #78f038 + --c6-accent: #ff9aae + --c7-accent: #606080 + --c8-accent: #aeaac0 + --c9-accent: #3ae0b8 + --c10-accent: #a828ba + --c11-accent: #1871d8 + --c12-accent: #885a18 + --c13-accent: #38a856 + --c14-accent: #ff3018 + --c15-accent: #ff1668 --light-text-primary: #181818 --light-text-alt: #444 @@ -25,6 +40,8 @@ --dark-background: #181a21 --dark-bg-sharp: #080808 + --accent: var(--c0-accent) + // the following are DEPRECATED // --light-accent: var(--accent) --dark-accent: var(--accent) @@ -49,7 +66,7 @@ --background: var(--dark-background) --bg-sharp: var(--dark-bg-sharp) -body.color-scheme-light +.color-scheme-light --text-primary: var(--light-text-primary) --text-alt: var(--light-text-alt) --border: var(--light-border) @@ -59,7 +76,7 @@ body.color-scheme-light --background: var(--light-background) --bg-sharp: var(--light-bg-sharp) -body.color-scheme-dark +.color-scheme-dark --text-primary: var(--dark-text-primary) --text-alt: var(--dark-text-alt) --border: var(--dark-border) @@ -69,6 +86,51 @@ body.color-scheme-dark --background: var(--dark-background) --bg-sharp: var(--dark-bg-sharp) +.color-theme-1 + --accent: var(--c1-accent) + +.color-theme-2 + --accent: var(--c2-accent) + +.color-theme-3 + --accent: var(--c3-accent) + +.color-theme-4 + --accent: var(--c4-accent) + +.color-theme-5 + --accent: var(--c5-accent) + +.color-theme-6 + --accent: var(--c6-accent) + +.color-theme-7 + --accent: var(--c7-accent) + +.color-theme-8 + --accent: var(--c8-accent) + +.color-theme-9 + --accent: var(--c9-accent) + +.color-theme-10 + --accent: var(--c10-accent) + +.color-theme-11 + --accent: var(--c11-accent) + +.color-theme-12 + --accent: var(--c12-accent) + +.color-theme-13 + --accent: var(--c13-accent) + +.color-theme-14 + --accent: var(--c14-accent) + +.color-theme-15 + --accent: var(--c15-accent) + body, input, select, button font-family: $ui-fonts diff --git a/freak/static/sass/layout.sass b/freak/static/sass/layout.sass index 94c1ac3..efb58ef 100644 --- a/freak/static/sass/layout.sass +++ b/freak/static/sass/layout.sass @@ -54,6 +54,11 @@ header.header &, > ul, > ul > li:has(.mini-search-bar) flex: 1 + + ul > li span + color: var(--text-primary) + font-size: .6em + .header-username > * display: block @@ -135,6 +140,7 @@ ul.inline list-style: none padding: 0 margin: 0 + display: inline > li display: inline &::before @@ -144,6 +150,22 @@ ul.inline content: '' margin: 0 +ul.grid + list-style: none + padding: 0 + display: grid + grid-template-columns: 1fr 1fr 1fr 1fr + grid-template-rows: auto + > li + border: 1px solid var(--border) + border-radius: .5em + padding: .5em + margin: 1em .5em + text-align: center + small + display: block + + ul.message-options color: var(--text-alt) list-style: none @@ -280,7 +302,7 @@ button, [type="submit"], [type="reset"], [type="button"] &.primary background-color: var(--accent) - color: var(--bg-main) + color: var(--background) &[disabled] opacity: .5 @@ -306,5 +328,27 @@ button, [type="submit"], [type="reset"], [type="button"] width: 0 margin-right: auto +.border-accent + border: var(--accent) 1px solid + display: inline-flex + align-items: center + padding: 0 4px +.round + border-radius: 1em + +.done + opacity: .5 + +button.card + width: 100% + padding: .5em 1em + background-color: transparent + border-color: var(--accent) + color: var(--accent) + border-radius: 1em + + &.primary + background-color: var(--accent) + color: var(--background) diff --git a/freak/static/sass/mobile.sass b/freak/static/sass/mobile.sass index 25a8e02..2ed1831 100644 --- a/freak/static/sass/mobile.sass +++ b/freak/static/sass/mobile.sass @@ -6,11 +6,19 @@ .content-nav, .content-main width: 100% + ul.grid + grid-template-columns: 1fr 1fr + + .nomobile + display: none + @media screen and (max-width: 960px) .header-username display: none header.header + padding: .5em .5em + .mini-search-bar display: none @@ -18,4 +26,11 @@ display: inline-block ul > li:has(.mini-search-bar) - flex: unset \ No newline at end of file + flex: unset + + +// not mobile: // + +@media screen and (min-width: 801px) + .mobileonly + display: none \ No newline at end of file diff --git a/freak/templates/admin/admin_base.html b/freak/templates/admin/admin_base.html index 11a0306..a61de60 100644 --- a/freak/templates/admin/admin_base.html +++ b/freak/templates/admin/admin_base.html @@ -6,11 +6,13 @@ - + {% for private_style in private_styles %} + + {% endfor %} - +
{% for message in get_flashed_messages() %} diff --git a/freak/templates/admin/admin_home.html b/freak/templates/admin/admin_home.html index ad49860..bdaa7ae 100644 --- a/freak/templates/admin/admin_home.html +++ b/freak/templates/admin/admin_home.html @@ -1,9 +1,12 @@ {% extends "admin/admin_base.html" %} {% block content %} -
    + {% endblock %} \ No newline at end of file diff --git a/freak/templates/admin/admin_report_detail.html b/freak/templates/admin/admin_report_detail.html index 8844c5a..370134e 100644 --- a/freak/templates/admin/admin_report_detail.html +++ b/freak/templates/admin/admin_report_detail.html @@ -1,5 +1,6 @@ {% extends "admin/admin_base.html" %} {% from "macros/embed.html" import embed_post with context %} +{% from "macros/icon.html" import icon, callout with context %} {% block content %}

    Report detail #{{ report.id }}

    @@ -14,10 +15,20 @@ {% else %}

    Unknown media type

    {% endif %} + {% if report.is_critical() %} + {% call callout('nsfw_language') %} + This is a critical offense. “Strike” will immediately suspend the offender's account. + {% endcall %} + {% endif %}
    + {% if report.is_critical() %} + + {% else %} + + {% endif %}
    {% endblock %} diff --git a/freak/templates/admin/admin_strikes.html b/freak/templates/admin/admin_strikes.html new file mode 100644 index 0000000..87b71a1 --- /dev/null +++ b/freak/templates/admin/admin_strikes.html @@ -0,0 +1,21 @@ +{% extends "admin/admin_base.html" %} +{% from "macros/feed.html" import stop_scrolling, no_more_scrolling with context %} + +{% block content %} +
      + {% for strike in strike_list %} +
    • +

      #{{ strike.id }} to {{ strike.user.handle() }}

      +
        +
      • Reason: {{ report_reasons[strike.reason_code] }}
      • + +
      +
    • + {% endfor %} + {% if strike_list.has_next %} + {{ stop_scrolling(strike_list.page) }} + {% else %} + {{ no_more_scrolling(strike_list.page) }} + {% endif %} +
    +{% endblock %} \ No newline at end of file diff --git a/freak/templates/base.html b/freak/templates/base.html index 7cab856..579cee4 100644 --- a/freak/templates/base.html +++ b/freak/templates/base.html @@ -4,6 +4,7 @@ + {% from "macros/icon.html" import icon with context %} {% block title %} {{ app_name }} {% endblock %} @@ -25,7 +26,7 @@ - +

    {{ app_name }}

    @@ -45,30 +46,28 @@ {% if g.no_user %} {% elif current_user.is_authenticated %} -
  • +
  • + create -
  • - - profile -
  • + New post + +
  • +
  • {{ icon('profile')}}profile
    - @{{ current_user.username }} - {{ current_user.karma }} karma -
    -
  • + {{ current_user.handle() }} + {{ icon('karma') }} {{ current_user.karma }} karma +
  • - log out + {{ icon('logout') }} log out
  • {% else %}
  • - - log in -
  • - - register -
  • + {{ icon('logout') }}log in + +
  • + {{ icon('join') }}register +
  • {% endif %}
@@ -104,9 +103,9 @@ function changeAccentColorTime() { let hours = (new Date).getHours(); if (hours < 6 || hours >= 19) { - document.body.style.setProperty('--accent', '#1871d8'); + document.body.classList.add('night'); } else { - document.body.style.removeProperty('--accent'); + document.body.classList.remove('night'); } } changeAccentColorTime(); diff --git a/freak/templates/feed.html b/freak/templates/feed.html index 239f66b..281de92 100644 --- a/freak/templates/feed.html +++ b/freak/templates/feed.html @@ -21,7 +21,7 @@ {% endif %} {% if feed_type == 'guild' %} - {{ nav_guild(topic) }} + {{ nav_guild(guild) }} {% endif %}