diff --git a/CHANGELOG.md b/CHANGELOG.md index a15077f..e5bc9eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,10 +2,13 @@ ## 0.4.0 -- Added dependency to [SUOU](https://github.com/sakuragasaki46/suou) -- Added user strikes, memberships and user blocks +- 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 +- Implemented guild subscriptions + + Blocking a user prevents them from seeing your comments, posts (standalone or in feed) and profile - Added ✨color themes✨ -- Users can now set their display name and biography in `/settings` +- Users can now set their display name, biography and color theme in `/settings` ## 0.3.3 diff --git a/freak/ajax.py b/freak/ajax.py index 82abb9d..d0da1e5 100644 --- a/freak/ajax.py +++ b/freak/ajax.py @@ -6,11 +6,13 @@ AJAX hooks for the website. ''' import re -from flask import Blueprint, request +from flask import Blueprint, abort, flash, redirect, request from sqlalchemy import delete, insert, select -from .models import Guild, db, User, Post, PostUpvote +from .models import Guild, Member, UserBlock, db, User, Post, PostUpvote from flask_login import current_user, login_required +current_user: User + bp = Blueprint('ajax', __name__) @bp.route('/username_availability/') @@ -71,3 +73,81 @@ def post_upvote(id): db.session.commit() return { 'status': 'ok', 'count': 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' + + 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()}') + + 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() + + if gu is None: + abort(404) + + is_join = 'reverse' not in request.form + is_leave = request.form.get('reverse') == '1' + + membership = db.session.execute(select(Member).where(Member.guild == gu, Member.user_id == current_user.id)).scalar() + + 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()}") + + 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 + + db.session.commit() + flash(f"Unsubscribed from {gu.handle()}.") + + return redirect(gu.url()), 303 + + diff --git a/freak/algorithms.py b/freak/algorithms.py index bd43525..04d8258 100644 --- a/freak/algorithms.py +++ b/freak/algorithms.py @@ -1,8 +1,10 @@ from flask_login import current_user -from sqlalchemy import func, select -from .models import db, Post, Guild, User +from sqlalchemy import and_, distinct, func, select +from .models import Comment, Member, db, Post, Guild, User + +current_user: User def cuser() -> User: return current_user if current_user.is_authenticated else None @@ -22,13 +24,18 @@ def topic_timeline(gname): 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(), User.has_not_blocked(Post.author_id, cuser_id()) + Post.visible_by(cuser_id()), 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(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()) + 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, + Comment.not_removed(), User.has_not_blocked(Comment.author_id, cuser_id())).order_by(Comment.created_at.desc()) diff --git a/freak/filters.py b/freak/filters.py index 378cfc5..df56edd 100644 --- a/freak/filters.py +++ b/freak/filters.py @@ -2,7 +2,7 @@ import markdown from markupsafe import Markup -from suou import Snowflake +from suou import Siq, Snowflake from suou.markdown import StrikethroughExtension, SpoilerExtension, PingExtension from . import app @@ -29,6 +29,12 @@ def to_b32l(n): app.template_filter('b32l')(to_b32l) +@app.template_filter() +def to_cb32(n): + return '0' + Siq.from_bytes(n).to_cb32() + +app.template_filter('cb32')(to_cb32) + @app.template_filter() def append(text, l: list): l.append(text) diff --git a/freak/models.py b/freak/models.py index 3f0b480..009bff7 100644 --- a/freak/models.py +++ b/freak/models.py @@ -214,10 +214,28 @@ 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: + return False + return bool(db.session.execute(select(UserBlock).where(UserBlock.c.actor_id == self.id, UserBlock.c.target_id == other.id)).scalar()) + + @not_implemented() + def end_friendship(self, other: User): + """ + Remove any relationship between two users. + Executed before block. + """ + # TODO implement in 0.5 + ... + + def has_subscriber(self, other: User) -> bool: + # TODO implement in 0.5 + return False #bool(db.session.execute(select(Friendship).where(...)).scalar()) + @classmethod def has_not_blocked(cls, actor, target): """ - Filter out a content if the author has blocked current user. + Filter out a content if the author has blocked current user. Returns a query. XXX untested. """ @@ -242,6 +260,8 @@ class User(Base): def strike_count(self) -> int: return db.session.execute(select(func.count('*')).select_from(UserStrike).where(UserStrike.user_id == self.id)).scalar() +# UserBlock table is at the top !! + ## END User class Guild(Base): @@ -272,9 +292,16 @@ 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() + # utilities posts = relationship('Post', back_populates='guild') + 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()) Topic = deprecated('renamed to Guild')(Guild) @@ -306,7 +333,7 @@ class Member(Base): 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) + banned_by = relationship(User, primaryjoin = lambda: User.id == Member.banned_by_id) @property def is_banned(self): @@ -356,9 +383,9 @@ class Post(Base): def url(self): 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) + @not_implemented('slugify is not a dependency as of now') + 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() @@ -397,8 +424,8 @@ class Post(Base): return Post.removed_at == None @classmethod - def visible_by(cls, user: User): - return or_(Post.author_id == user.id, Post.privacy.in_((0, 1))) + def visible_by(cls, user_id: int | None): + return or_(Post.author_id == user_id, Post.privacy.in_((0, 1))) class Comment(Base): diff --git a/freak/rest/__init__.py b/freak/rest/__init__.py index b203afa..0957ad5 100644 --- a/freak/rest/__init__.py +++ b/freak/rest/__init__.py @@ -1,15 +1,20 @@ -from flask import Blueprint -from flask_restx import Resource, Api +from flask import Blueprint, redirect, url_for +from flask_restx import Resource from sqlalchemy import select from suou import Snowflake +from suou.flask_sqlalchemy import require_auth + +from suou.flask_restx import Api from ..models import Post, User, db rest_bp = Blueprint('rest', __name__, url_prefix='/v1') rest = Api(rest_bp) +auth_required = require_auth(User, db) + @rest.route('/nurupo') class Nurupo(Resource): def get(self): @@ -18,9 +23,20 @@ class Nurupo(Resource): ## TODO coverage of REST is still partial, but it's planned ## to get complete sooner or later +## XXX there is a bug in suou.sqlalchemy.auth_required() — apparently, /user/@me does not +## 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() if u is None: return dict(error='User not found'), 404 @@ -34,6 +50,7 @@ class UserInfo(Resource): ) return dict(users={f'{Snowflake(id):l}': uj}) + @rest.route('/post/') class SinglePost(Resource): def get(self, id: int): @@ -48,4 +65,4 @@ class SinglePost(Resource): created_at = p.created_at.isoformat('T') ) - return dict(posts={f'{Snowflake(id):l}': pj}) \ No newline at end of file + return dict(posts={f'{Snowflake(id):l}': pj}) diff --git a/freak/static/sass/base.sass b/freak/static/sass/base.sass index a45f049..32cfe22 100644 --- a/freak/static/sass/base.sass +++ b/freak/static/sass/base.sass @@ -15,7 +15,7 @@ --c7-accent: #606080 --c8-accent: #aeaac0 --c9-accent: #3ae0b8 - --c10-accent: #a828ba + --c10-accent: #8828ea --c11-accent: #1871d8 --c12-accent: #885a18 --c13-accent: #38a856 diff --git a/freak/static/sass/content.sass b/freak/static/sass/content.sass index 56354ec..e30a6c1 100644 --- a/freak/static/sass/content.sass +++ b/freak/static/sass/content.sass @@ -14,15 +14,15 @@ blockquote padding-right: 1em border-right: 4px solid var(--border) +.callout + color: var(--text-alt) + .success color: var(--success) .error color: var(--error) -.callout - color: var(--text-alt) - .message-content p margin: 4px 0 diff --git a/freak/static/sass/layout.sass b/freak/static/sass/layout.sass index efb58ef..9dbd8c9 100644 --- a/freak/static/sass/layout.sass +++ b/freak/static/sass/layout.sass @@ -103,9 +103,9 @@ header.header // __ aside styles __ // aside.card overflow: hidden - > :first-child + > :is(h1, h2, h3, h4, h5, h6):first-child background-color: var(--accent) - padding: 12px + padding: 6px 12px margin: -12px -12px 0 -12px position: relative a @@ -120,6 +120,9 @@ aside.card padding: 12px &:last-child border-bottom: none + > p + padding: 12px + margin: 0 .flash @@ -171,8 +174,6 @@ ul.message-options list-style: none padding: 0 font-size: smaller - .comment-frame & - margin-bottom: -4px .post-frame margin-left: 3em @@ -321,12 +322,30 @@ button, [type="submit"], [type="reset"], [type="button"] .comment-frame border: 1px solid var(--border) - padding: 12px + background: var(--background) + padding: 12px 12px 6px border-radius: 24px border-start-start-radius: 0 min-width: 50% width: 0 - margin-right: auto + margin-inline-end: auto + margin-bottom: 12px + position: relative + &::before + content: '' + border: 1px solid var(--border) + border-inline-end: 0 + border-bottom: 0 + background: var(--background) + height: 1em + width: 1em + position: absolute + left: calc(-1px - .5em) + top: -1px + transform: skewX(45deg) + li:has(> &) + list-style: none + .border-accent border: var(--accent) 1px solid @@ -352,3 +371,8 @@ button.card background-color: var(--accent) color: var(--background) +.big_icon + display: block + margin: 12px auto + font-size: 36px + text-align: center diff --git a/freak/templates/admin/admin_base.html b/freak/templates/admin/admin_base.html index a61de60..fd0037d 100644 --- a/freak/templates/admin/admin_base.html +++ b/freak/templates/admin/admin_base.html @@ -11,18 +11,18 @@ {% endfor %} - +
{% for message in get_flashed_messages() %}
{{ message }}
{% endfor %} {% block content %}{% endblock %}
- + diff --git a/freak/templates/admin/admin_strikes.html b/freak/templates/admin/admin_strikes.html index 87b71a1..cab08c2 100644 --- a/freak/templates/admin/admin_strikes.html +++ b/freak/templates/admin/admin_strikes.html @@ -5,7 +5,7 @@
    {% for strike in strike_list %}
  • -

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

    +

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

    • Reason: {{ report_reasons[strike.reason_code] }}
    • diff --git a/freak/templates/create.html b/freak/templates/create.html index 49af222..9ca3291 100644 --- a/freak/templates/create.html +++ b/freak/templates/create.html @@ -9,7 +9,6 @@ {% endblock %} {% block content %} -
      diff --git a/freak/templates/edit.html b/freak/templates/edit.html index 631255d..b2447ac 100644 --- a/freak/templates/edit.html +++ b/freak/templates/edit.html @@ -1,6 +1,7 @@ {% extends "base.html" %} {% from "macros/title.html" import title_tag with context %} {% from "macros/create.html" import privacy_select with context %} +{% from "macros/icon.html" import icon, callout with context %} {% block title %}{{ title_tag('Editing: ' + p.title, False) }}{% endblock %} @@ -16,10 +17,12 @@ Text:
      -
      {{ privacy_select(p.privacy) }}
      -
      +
      {{ privacy_select(p.privacy) }}
      +
      +
      +

      {{ icon('delete') }} Delete post

      {% endblock %} \ No newline at end of file diff --git a/freak/templates/macros/button.html b/freak/templates/macros/button.html new file mode 100644 index 0000000..106f114 --- /dev/null +++ b/freak/templates/macros/button.html @@ -0,0 +1,27 @@ + + +{% from "macros/icon.html" import icon with context%} + +{% macro block_button(target, blocked = False) %} +
      + + {% if blocked %} + + + {% else %} + +{% endif %} +
      +{% endmacro %} + +{% macro subscribe_button(target, subbed = False) %} +
      + + {% if subbed %} + + + {% else %} + +{% endif %} +
      +{% endmacro %} diff --git a/freak/templates/macros/create.html b/freak/templates/macros/create.html index 1155e08..61fd95f 100644 --- a/freak/templates/macros/create.html +++ b/freak/templates/macros/create.html @@ -1,4 +1,6 @@ +{% from "macros/icon.html" import icon with context %} + {% macro checked_if(cond) %} {% if cond -%} checked="" @@ -12,11 +14,11 @@ disabled="" {% endmacro %} {% macro privacy_select(value = 0) %} -
        -
      • -
      • -
      • -
      • +
          +
        • +
        • +
        • +
        {% endmacro %} diff --git a/freak/templates/macros/icon.html b/freak/templates/macros/icon.html index aad078b..d91d430 100644 --- a/freak/templates/macros/icon.html +++ b/freak/templates/macros/icon.html @@ -3,9 +3,13 @@ {% endmacro %} -{% macro callout(useicon = "spoiler") %} -
        +{% macro callout(useicon = "spoiler", classes = "") %} +
        {{ icon(useicon) }} {{ caller() }}
        +{% endmacro %} + +{% macro big_icon(name, fill = False) %} +
        {{ icon(name, fill) }}
        {% endmacro %} \ No newline at end of file diff --git a/freak/templates/macros/nav.html b/freak/templates/macros/nav.html index a7f2ead..7bf4cd1 100644 --- a/freak/templates/macros/nav.html +++ b/freak/templates/macros/nav.html @@ -1,4 +1,7 @@ +{% from "macros/icon.html" import icon with context %} +{% from "macros/button.html" import block_button, subscribe_button with context %} + {% macro nav_guild(gu) %}
      + {% if current_user.is_authenticated %} + {{ subscribe_button(gu, gu.has_subscriber(current_user)) }} + {% endif %} {% endmacro %} {% macro nav_user(user) %} + {% if user == current_user %} + + {% elif current_user.is_authenticated %} + {{ block_button(user, current_user.has_blocked(user)) }} + {{ subscribe_button(user, user.has_subscriber(current_user)) }} + {% else %} + + {% endif %} {% endmacro %} {% macro nav_top_communities(top_communities) %} @@ -35,4 +54,4 @@ {% endif %}
    -{% 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 = [