diff --git a/CHANGELOG.md b/CHANGELOG.md index 988919e..0177537 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,12 +1,5 @@ # Changelog -## 0.5.0 - -- Switched to Quart framework. This implies everything is `async def` now. -- **BREAKING**: `SERVER_NAME` env variable now contains the domain name. `DOMAIN_NAME` has been removed. -- libsuou bumped to 0.6.0 -- Added several REST routes. Change needed due to pending frontend separation. - ## 0.4.0 - Added dependency to [SUOU](https://github.com/sakuragasaki46/suou) library diff --git a/README.md b/README.md index e2c9f28..1308d66 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. - * `SERVER_NAME` (see above) + * `DOMAIN_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 9dbe0e7..331cbe1 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 . - hypercorn freak:app -b 0.0.0.0:5000 + flask --app freak run --host=0.0.0.0 } [[ "$1" = "" ]] && start-app diff --git a/freak/__init__.py b/freak/__init__.py index 7162627..607b400 100644 --- a/freak/__init__.py +++ b/freak/__init__.py @@ -1,32 +1,30 @@ -import logging import re from sqlite3 import ProgrammingError -import sys from typing import Any import warnings -from quart import ( - Quart, flash, g, jsonify, redirect, render_template, +from flask import ( + Flask, g, redirect, render_template, request, send_from_directory, url_for ) import os import dotenv -from quart_auth import AuthUser, QuartAuth, Action as QA_Action, current_user -from quart_wtf import CSRFProtect -from sqlalchemy import inspect, select +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 suou.sass import SassAsyncMiddleware -from suou.quart import negotiate -from hypercorn.middleware import ProxyFixMiddleware +from sassutils.wsgi import SassMiddleware +from werkzeug.middleware.proxy_fix import ProxyFix 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-dev36' +__version__ = '0.4.0' APP_BASE_DIR = os.path.dirname(os.path.dirname(__file__)) @@ -37,42 +35,31 @@ class AppConfig(ConfigOptions): secret_key = ConfigValue(required=True) database_url = ConfigValue(required=True) app_name = ConfigValue() - server_name = ConfigValue() + domain_name = ConfigValue() private_assets = ConfigValue(cast=ssv_list) - # deprecated jquery_url = ConfigValue(default='https://ajax.googleapis.com/ajax/libs/jquery/3.7.1/jquery.min.js') - app_is_behind_proxy = ConfigValue(cast=int, default=0) + 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() -logging.basicConfig(level=logging.WARNING) - -logger = logging.getLogger(__name__) - -app = Quart(__name__) +app = Flask(__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 - -## DO NOT ADD LOCAL IMPORTS BEFORE THIS LINE - -from .accounts import UserLoader -from .models import Guild, db, User, Post +from .models import db, User, Post # SASS -app.asgi_app = SassAsyncMiddleware(app.asgi_app, dict( +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.asgi_app = ProxyFixMiddleware( - app.asgi_app, trusted_hops=app_config.app_is_behind_proxy, mode='legacy' + app.wsgi_app = ProxyFix( + app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1 ) class SlugConverter(BaseConverter): @@ -88,169 +75,100 @@ class B32lConverter(BaseConverter): app.url_map.converters['slug'] = SlugConverter app.url_map.converters['b32l'] = B32lConverter -db.bind(app_config.database_url) +db.init_app(app) csrf = CSRFProtect(app) - - - -# TODO configure quart_auth -login_manager = QuartAuth(app, user_class= UserLoader) +login_manager = LoginManager(app) +login_manager.login_view = 'accounts.login' from . import filters PRIVATE_ASSETS = os.getenv('PRIVATE_ASSETS', '').split() -post_count_cache = 0 -user_count_cache = 0 - @app.context_processor -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 - +def _inject_variables(): return { 'app_name': app_config.app_name, 'app_version': __version__, - 'server_name': app_config.server_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 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_count, + 'post_count': Post.count(), + 'user_count': User.active_count(), 'colors': color_themes, 'theme_classes': theme_classes, 'impressum': '\n'.join(app_config.impressum).replace('_', ' ') } -@app.before_request -async def _load_user(): +@login_manager.user_loader +def _inject_user(userid): try: - await current_user._load() - except RuntimeError as e: - logger.error(f'{e}') + 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 - - -@app.after_request -async def _unload_user(resp): - try: - await current_user._unload() - except RuntimeError as e: - logger.error(f'{e}') - return resp - + return None 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) -async def error_db(body): +def error_db(body): g.no_user = True - 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') + 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) -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') - +def error_400(body): + return render_template('400.html'), 400 @app.errorhandler(403) -async def error_403(body): - return await error_handler_for(403, body, '403.html') +def error_403(body): + return render_template('403.html'), 403 -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 +from .search import find_guild_or_user @app.errorhandler(404) -async def error_404(body): +def error_404(body): try: if mo := re.match(r'/([a-z0-9_-]+)/?', request.path): - alternative = await find_guild_or_user(mo.group(1)) + alternative = find_guild_or_user(mo.group(1)) if alternative is not None: return redirect(alternative), 302 except Exception as e: - logger.error(f'Exception in find_guild_or_user: {e}') + warnings.warn(f'Exception in find_guild_or_user: {e}') pass - print(request.host) - return await error_handler_for(404, 'Not found', '404.html') + return render_template('404.html'), 404 @app.errorhandler(405) -async def error_405(body): - return await error_handler_for(405, body, '405.html') +def error_405(body): + return render_template('405.html'), 405 @app.errorhandler(451) -async def error_451(body): - return await error_handler_for(451, body, '451.html') +def error_451(body): + return render_template('451.html'), 451 @app.errorhandler(500) -async def error_500(body): +def error_500(body): g.no_user = True - return await error_handler_for(500, body, '500.html') + return render_template('500.html'), 500 @app.route('/favicon.ico') -async def favicon_ico(): - return await send_from_directory(APP_BASE_DIR, 'favicon.ico') +def favicon_ico(): + return send_from_directory(APP_BASE_DIR, 'favicon.ico') @app.route('/robots.txt') -async def robots_txt(): - return await send_from_directory(APP_BASE_DIR, 'robots.txt') +def robots_txt(): + return send_from_directory(APP_BASE_DIR, 'robots.txt') from .website import blueprints @@ -260,8 +178,8 @@ for bp in blueprints: from .ajax import bp app.register_blueprint(bp) -from .rest import bp -app.register_blueprint(bp) +from .rest import rest_bp +app.register_blueprint(rest_bp) diff --git a/freak/__main__.py b/freak/__main__.py index 0f15538..df77c43 100644 --- a/freak/__main__.py +++ b/freak/__main__.py @@ -1,6 +1,4 @@ -import asyncio from .cli import main -asyncio.run(main()) - +main() \ No newline at end of file diff --git a/freak/accounts.py b/freak/accounts.py deleted file mode 100644 index 8951426..0000000 --- a/freak/accounts.py +++ /dev/null @@ -1,83 +0,0 @@ - - -import logging -import enum - -from sqlalchemy import select -from sqlalchemy.orm import selectinload -from .models import User, db -from quart_auth import AuthUser, Action as _Action - -logger = logging.getLogger(__name__) - -class LoginStatus(enum.Enum): - SUCCESS = 0 - ERROR = 1 - SUSPENDED = 2 - PASS_EXPIRED = 3 - -def check_login(user: User | None, password: str) -> LoginStatus: - try: - if user is None: - return LoginStatus.ERROR - if ('$' not in user.passhash) and user.email: - return LoginStatus.PASS_EXPIRED - if not user.is_active: - return LoginStatus.SUSPENDED - if user.check_password(password): - return LoginStatus.SUCCESS - except Exception as e: - logger.error(f'{e}') - return LoginStatus.ERROR - - -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: _Action= _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: - async with db as session: - 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 - username: str - display_name: str diff --git a/freak/ajax.py b/freak/ajax.py index 19e964c..00107ed 100644 --- a/freak/ajax.py +++ b/freak/ajax.py @@ -1,35 +1,29 @@ ''' -AJAX hooks for the OLD frontend. +AJAX hooks for the website. -DEPRECATED in 0.5 in favor of /v1/ (REST) +2025 DEPRECATED in favor of /v1/ (REST) ''' -from __future__ import annotations - import re -from quart import Blueprint, abort, flash, redirect, request +from flask import Blueprint, abort, flash, redirect, request from sqlalchemy import delete, insert, select - -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 +from flask_login import current_user, login_required -current_user: UserLoader +current_user: User bp = Blueprint('ajax', __name__) @bp.route('/username_availability/') @bp.route('/ajax/username_availability/') -async def username_availability(username: str): +def username_availability(username: str): is_valid = username_is_legal(username) if is_valid: - async with db as session: - user = (await session.execute(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.user + is_available = user is None or user == current_user else: is_available = False @@ -40,14 +34,13 @@ async def username_availability(username: str): } @bp.route('/guild_name_availability/') -async def guild_name_availability(name: str): +def guild_name_availability(name: str): is_valid = username_is_legal(name) if is_valid: - async with db as session: - gd = (await session.execute(select(Guild).where(Guild.name == name))).scalar() + gd = db.session.execute(select(Guild).where(Guild.name == name)).scalar() - is_available = gd is None + is_available = gd is None else: is_available = False @@ -59,112 +52,101 @@ async def guild_name_availability(name: str): @bp.route('/comments//upvote', methods=['POST']) @login_required -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() +def post_upvote(id): + o = request.form['o'] + 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 - - cur_score = await p.upvoted_by(current_user.user) + 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 - 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() } + db.session.commit() + return { 'status': 'ok', 'count': p.upvotes() } @bp.route('/@/block', methods=['POST']) @login_required -async def block_user(username): - form = await get_request_form() +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 with db as session: - u = (await session.execute(select(User).where(User.username == username))).scalar() + 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()}') - 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 -async def subscribe_guild(name): - form = await get_request_form() +def subscribe_guild(name): + gu = db.session.execute(select(Guild).where(Guild.name == name)).scalar() - async with db as session: - gu = (await 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' - if gu is None: - abort(404) - - is_join = 'reverse' not in form - is_leave = form.get('reverse') == '1' + membership = db.session.execute(select(Member).where(Member.guild == gu, Member.user_id == current_user.id)).scalar() - membership = (await 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_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()}") + 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_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()}.") + 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 911b905..04d8258 100644 --- a/freak/algorithms.py +++ b/freak/algorithms.py @@ -2,16 +2,15 @@ from flask_login import current_user from sqlalchemy import and_, distinct, func, select +from .models import Comment, Member, db, Post, Guild, User -from .models import Comment, Member, Post, Guild, User - - +current_user: User def cuser() -> User: - return current_user.user if current_user else None + return current_user if current_user.is_authenticated else None def cuser_id() -> int: - return current_user.id if current_user else None + 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( @@ -19,25 +18,24 @@ def public_timeline(): ).order_by(Post.created_at.desc()) def topic_timeline(gname): - return select(Post).join(Guild, Guild.id == Post.topic_id).join(User, User.id == Post.author_id).where( + 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: User): +def user_timeline(user_id): return select(Post).join(User, User.id == Post.author_id).where( - 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()) + 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(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).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, + 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()) - - -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 63b508a..34a1959 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_config +from . import __version__ as version, app 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 -async def main(): +def main(): args = make_parser().parse_args() engine = create_engine(os.getenv('DATABASE_URL')) @@ -26,19 +26,18 @@ async def main(): print(f'Schema upgrade failed (code: {ret_code})') exit(ret_code) # if the alembic/versions folder is empty - await db.create_all(engine) + db.metadata.create_all(engine) print('Schema upgraded!') if args.flush: cnt = 0 - async with db as session: - - for u in (await session.execute(select(User))).scalars(): + with app.app_context(): + for u in db.session.execute(select(User)).scalars(): u.recompute_karma() cnt += 1 - session.add(u) - session.commit() + db.session.add(u) + db.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 39171eb..2391f87 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, 'WhatsApp'), + ColorTheme(13, 'Leek'), ColorTheme(14, 'Teto'), ColorTheme(15, 'Ruby') ] 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/models.py b/freak/models.py index ef44748..caf4e72 100644 --- a/freak/models.py +++ b/freak/models.py @@ -8,29 +8,22 @@ from functools import partial from operator import or_ import re from threading import Lock -from typing import Any, Callable -from quart_auth import current_user -from sqlalchemy import Column, Integer, String, ForeignKey, UniqueConstraint, and_, insert, text, \ +from sqlalchemy import Column, Integer, String, ForeignKey, UniqueConstraint, insert, text, \ CheckConstraint, Date, DateTime, Boolean, func, BigInteger, \ SmallInteger, select, update, Table from sqlalchemy.orm import Relationship, relationship -from suou.sqlalchemy_async import SQLAlchemy -from suou import SiqType, Snowflake, Wanted, deprecated, makelist, not_implemented +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 -from . import app_config -from .utils import get_remote_addr +from freak import app_config +from .utils import age_and_days, get_remote_addr, timed_cache -from suou import timed_cache, age_and_days - -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 @@ -78,16 +71,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 somebody deleted suspended default bot developer undefined null ' - 'ai automod clanker automoderator assistant privacy anonymous removed assistance ' + '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 heilhitler kgb ' 'pedophile lolicon giphy tenor csam cp pedobear lolita lolice thanos ' - 'loli lolicon kkk pnf adl cop tranny google trustandsafety safety ice fbi nsa it ' + 'loli kkk pnf adl cop tranny google trustandsafety safety ice fbi nsa it ' ## VVVVIP 'potus realdonaldtrump elonmusk teddysphotos mrbeast jkrowling pewdiepie ' - 'elizabethii elizabeth2 king queen pontifex hogwarts lumos alohomora isis daesh retards ' + 'elizabethii king queen pontifex hogwarts lumos alohomora isis daesh ' ).split()) def username_is_legal(username: str) -> bool: @@ -101,26 +94,21 @@ 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.server_name, app_config.secret_key, +Base = declarative_base(app_config.domain_name, app_config.secret_key, snowflake_epoch=1577833200) db = SQLAlchemy(model_class=Base) CSI = create_session_interactively = partial(create_session, app_config.database_url) -## .accounts requires db -#current_user: UserLoader - +# 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. @@ -163,7 +151,6 @@ 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')) @@ -184,8 +171,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', lazy='selectin') - #comments = relationship("Comment", back_populates='author', lazy='selectin') + upvoted_posts = relationship("Post", secondary=PostUpvote, back_populates='upvoters') + #comments = relationship("Comment", back_populates='author') @property def is_disabled(self): @@ -202,16 +189,13 @@ 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) @@ -222,32 +206,26 @@ class User(Base): def age(self): return age_and_days(self.gdpr_birthday)[0] - def simple_info(self, *, typed = False): + def simple_info(self): """ Return essential informations for representing a user in the REST """ ## XXX change func name? - gg = dict( + return dict( id = Snowflake(self.id).to_b32l(), username = self.username, display_name = self.display_name, - age = self.age(), - badges = self.badges(), - + age = self.age() + ## TODO add badges? ) - if typed: - gg['type'] = 'user' - return gg - @deprecated('updates may be not atomic. DO NOT USE until further notice') - async def reward(self, points=1): + def reward(self, points=1): """ Manipulate a user's karma on the fly """ with Lock(): - async with db as session: - await session.execute(update(User).where(User.id == self.id).values(karma = self.karma + points)) - await session.commit() + 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 fully configurable @@ -262,12 +240,10 @@ class User(Base): return check_password_hash(self.passhash, password) @classmethod - @timed_cache(1800, async_=True) - async def active_count(cls) -> int: + @timed_cache(1800) + def active_count(cls) -> int: active_th = datetime.datetime.now() - datetime.timedelta(days=30) - 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 + 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() def __repr__(self): return f'<{self.__class__.__name__} id:{self.id!r} username:{self.username!r}>' @@ -276,25 +252,10 @@ class User(Base): def not_suspended(cls): return or_(User.banned_at == None, User.banned_until <= datetime.datetime.now()) - async def has_blocked(self, other: User | None) -> bool: - if not want_User(other, var_name='other', prefix='User.has_blocked()'): + def has_blocked(self, other: User | None) -> bool: + if other is None or not other.is_authenticated: return False - 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() + 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): @@ -307,10 +268,10 @@ class User(Base): def has_subscriber(self, other: User) -> bool: # TODO implement in 0.5 - return False #bool(session.execute(select(Friendship).where(...)).scalar()) + return False #bool(db.session.execute(select(Friendship).where(...)).scalar()) @classmethod - def has_not_blocked(cls, actor: int, target: int): + def has_not_blocked(cls, actor, target): """ Filter out a content if the author has blocked current user. Returns a query. @@ -324,64 +285,33 @@ class User(Base): qq= ~select(UserBlock).where(UserBlock.c.actor_id == actor_id, UserBlock.c.target_id == target_id).exists() return qq - 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 + 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() - return c + self.karma = c - ## 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() + @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() - 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() + 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() - 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 @@ -416,92 +346,63 @@ class Guild(Base): def handle(self): return f'+{self.name}' - 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 + def subscriber_count(self): + return db.session.execute(select(func.count('*')).select_from(Member).where(Member.guild == self, Member.is_subscribed == True)).scalar() # utilities - owner = relationship(User, foreign_keys=owner_id, lazy='selectin') - posts = relationship('Post', back_populates='guild', lazy='selectin') + owner = relationship(User, foreign_keys=owner_id) + posts = relationship('Post', back_populates='guild') - 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() - - async def has_subscriber(self, other: User) -> bool: - if not want_User(other, var_name='other', prefix='Guild.has_subscriber()'): + def has_subscriber(self, other: User) -> bool: + if other is None or not other.is_authenticated: return False - 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) + 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 has_exiled(self, other: User) -> bool: - if not want_User(other, var_name='other', prefix='Guild.has_exiled()'): + def has_exiled(self, other: User) -> bool: + if other is None or not other.is_authenticated: 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() + 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 - 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) + 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): return True + if self.is_restricted: + return (mem and mem.is_approved) + return True - 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 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 update_member(self, u: User | Member, /, **values): + def update_member(self, u: User | Member, /, **values): if isinstance(u, User): - async with db as session: - m = (await session.execute(select(Member).where(Member.user_id == u.id, Member.guild_id == self.id))).scalar() + 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() if m is None: - 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 + raise RuntimeError + return m else: m = u if len(values): - async with db as session: - session.execute(update(Member).where(Member.user_id == u.id, Member.guild_id == self.id).values(**values)) + db.session.execute(update(Member).where(Member.user_id == u.id, Member.guild_id == self.id).values(**values)) return m - - def simple_info(self, *, typed=False): - """ - Return essential informations for representing a guild in the REST - """ - ## XXX change func name? - gg = dict( - id = Snowflake(self.id).to_b32l(), - name = self.name, - display_name = self.display_name, - badges = [] - ) - if typed: - gg['type'] = 'guild' - return gg Topic = deprecated('renamed to Guild')(Guild) @@ -532,9 +433,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, lazy='selectin') - guild = relationship(Guild, lazy='selectin') - banned_by = relationship(User, primaryjoin = lambda: User.id == Member.banned_by_id, lazy='selectin') + 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): @@ -573,14 +474,10 @@ class Post(Base): removed_reason = Column(SmallInteger, nullable=True) # utilities - author: Relationship[User] = relationship("User", foreign_keys=[author_id], lazy='selectin')#, back_populates="posts") + author: Relationship[User] = relationship("User", lazy='selectin', foreign_keys=[author_id])#, back_populates="posts") guild: Relationship[Guild] = relationship("Guild", back_populates="posts", lazy='selectin') - 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() + comments = relationship("Comment", back_populates="parent_post") + upvoters = relationship("User", secondary=PostUpvote, back_populates='upvoted_posts') def topic_or_user(self) -> Guild | User: return self.guild or self.author @@ -592,41 +489,33 @@ class Post(Base): def generate_slug(self) -> str: return "slugify.slugify(self.title, max_length=64)" - 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 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 upvoted_by(self, user: User | None): - if not want_User(user, var_name='user', prefix='Post.upvoted_by()'): + def upvoted_by(self, user: User | AnonymousUserMixin | None): + if not user or not user.is_authenticated: return 0 - 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,): + 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 - if v == (False,): - return 1 - logger.warning(f'unexpected value: {v}') - return 0 + return 1 + return 0 - 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 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() def report_url(self) -> str: return f'/report/post/{Snowflake(self.id):l}' - 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() + 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() @classmethod - @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() + @timed_cache(1800) + def count(cls): + return db.session.execute(select(func.count('*')).select_from(cls)).scalar() @property def is_removed(self) -> bool: @@ -638,21 +527,8 @@ class Post(Base): @classmethod def visible_by(cls, user_id: int | None): - 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))) + return or_(Post.author_id == user_id, Post.privacy.in_((0, 1))) - def is_text_post(self): - return self.post_type == POST_TYPE_DEFAULT - - def feed_info(self): - return dict( - id=Snowflake(self.id).to_b32l(), - slug = self.slug, - title = self.title, - author = self.author.simple_info(), - to = self.topic_or_user().simple_info(), - created_at = self.created_at - ) class Comment(Base): __tablename__ = 'freak_comment' @@ -678,8 +554,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], lazy='selectin')#, back_populates='comments') - parent_post: Relationship[Post] = relationship("Post", back_populates="comments", foreign_keys=[parent_post_id], lazy='selectin') + author = relationship('User', foreign_keys=[author_id])#, back_populates='comments') + 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): @@ -688,9 +564,8 @@ class Comment(Base): def report_url(self) -> str: return f'/report/comment/{Snowflake(self.id):l}' - 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() + 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() @property def is_removed(self) -> bool: @@ -713,16 +588,15 @@ 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', lazy='selectin') + author = relationship('User') - 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 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 def is_critical(self): return self.reason_code in ( @@ -742,10 +616,9 @@ 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, lazy='selectin') - issued_by = relationship(User, primaryjoin= lambda: User.id == UserStrike.issued_by_id, lazy='selectin') + 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 903c055..0957ad5 100644 --- a/freak/rest/__init__.py +++ b/freak/rest/__init__.py @@ -1,224 +1,68 @@ -from __future__ import annotations -from flask import abort -from pydantic import BaseModel -from quart import Blueprint, redirect, request, url_for -from quart_auth import AuthUser, current_user, login_required, login_user, logout_user -from quart_schema import QuartSchema, validate_request, validate_response +from flask import Blueprint, redirect, url_for +from flask_restx import Resource from sqlalchemy import select -from suou import Snowflake, deprecated, makelist, not_implemented, want_isodate - -from werkzeug.security import check_password_hash -from suou.quart import add_rest - -from freak.accounts import LoginStatus, check_login -from freak.algorithms import topic_timeline, user_timeline - -from ..models import Guild, Post, User, db -from .. import UserLoader, app, app_config, __version__ as freak_version, csrf - -bp = Blueprint('rest', __name__, url_prefix='/v1') -rest = add_rest(app, '/v1', '/ajax') - -## XXX potential security hole, but needed for REST to work -csrf.exempt(bp) - -current_user: UserLoader - -## TODO deprecate auth_required since it does not work -## will be removed in 0.6 +from suou import Snowflake 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 +from suou.flask_restx import Api -@bp.get('/nurupo') -async def get_nurupo(): - return dict(ga=-1) +from ..models import Post, User, db -@bp.get('/health') -async def health(): - async with db as session: - hi = dict( - version=freak_version, - name = app_config.app_name, - post_count = await Post.count(), - user_count = await User.active_count(), - me = Snowflake(current_user.id).to_b32l() if current_user else None - ) +rest_bp = Blueprint('rest', __name__, url_prefix='/v1') +rest = Api(rest_bp) - return hi +auth_required = require_auth(User, db) -@bp.get('/oath') -async def oath(): - return dict( - ## XXX might break any time! - csrf_token= await csrf._get_csrf_token() - ) +@rest.route('/nurupo') +class Nurupo(Resource): + def get(self): + return dict(nurupo='ga') ## 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. It was therefore dismissed. +## redirect, neither is able to get user injected. ## Auth-based REST endpoints won't be fully functional until 0.6 in most cases -## USERS ## +@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 -@bp.get('/user/@me') -@login_required -async def get_user_me(): - return redirect(url_for(f'rest.user_get', id=current_user.id)), 302 - -def _user_info(u: User): - return dict( +@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 + uj = dict( id = f'{Snowflake(u.id):l}', username = u.username, display_name = u.display_name, - joined_at = want_isodate(u.joined_at), + joined_at = u.joined_at.isoformat('T'), karma = u.karma, - age = u.age(), - biography=u.biography, - badges = u.badges() + age = u.age() ) + return dict(users={f'{Snowflake(id):l}': uj}) -@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 = _user_info(u) - return dict(users={f'{Snowflake(id):l}': uj}) -@bp.get('/user//feed') -async def user_feed_get(id: int): - 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 = _user_info(u) - - feed = [] - algo = user_timeline(u) - posts = await db.paginate(algo) - async for p in posts: - feed.append(p.feed_info()) - - return dict(users={f'{Snowflake(id):l}': uj}, feed=feed) - -@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 - -@bp.get('/user/@/feed') -async def resolve_user_feed(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_feed_get', id=uid)), 302 - -## POSTS ## - -@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() +@rest.route('/post/') +class SinglePost(Resource): + def get(self, id: int): + 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 = f'{Snowflake(p.id):l}', title = p.title, author = p.author.simple_info(), - to = p.topic_or_user().simple_info(typed=True), + to = p.topic_or_user().handle(), created_at = p.created_at.isoformat('T') ) - if p.is_text_post(): - pj['content'] = p.text_content - - return dict(posts={f'{Snowflake(id):l}': pj}) - -## GUILDS ## - -async def _guild_info(gu: Guild): - return dict( - id = f'{Snowflake(gu.id):l}', - name = gu.name, - display_name = gu.display_name, - description = gu.description, - created_at = want_isodate(gu.created_at), - badges = [] - ) - -@bp.get('/guild/@') -async def guild_info_only(gname: str): - async with db as session: - gu: Guild | None = (await session.execute(select(Guild).where(Guild.name == gname))).scalar() - - if gu is None: - return dict(error='Not found'), 404 - gj = await _guild_info(gu) - - return dict(guilds={f'{Snowflake(gu.id):l}': gj}) - - -@bp.get('/guild/@/feed') -async def guild_feed(gname: str): - async with db as session: - gu: Guild | None = (await session.execute(select(Guild).where(Guild.name == gname))).scalar() - - if gu is None: - return dict(error='Not found'), 404 - gj = await _guild_info(gu) - feed = [] - algo = topic_timeline(gname) - posts = await db.paginate(algo) - async for p in posts: - feed.append(p.feed_info()) - - return dict(guilds={f'{Snowflake(gu.id):l}': gj}, feed=feed) - -## LOGIN/OUT ## - -class LoginIn(BaseModel): - username: str - password: str - remember: bool = False - -@bp.post('/login') -@validate_request(LoginIn) -async def login(data: LoginIn): - async with db as session: - u = (await session.execute(select(User).where(User.username == data.username))).scalar() - match check_login(u, data.password): - case LoginStatus.SUCCESS: - remember_for = int(data.remember) - if remember_for > 0: - login_user(UserLoader(u.get_id()), remember=True) - else: - login_user(UserLoader(u.get_id())) - return {'id': f'{Snowflake(u.id):l}'}, 200 - case LoginStatus.ERROR: - abort(404, 'Invalid username or password') - case LoginStatus.SUSPENDED: - abort(403, 'Your account is suspended') - case LoginStatus.PASS_EXPIRED: - abort(403, 'You need to reset your password following the procedure.') - - -@bp.post('/logout') -@login_required -async def logout(): - logout_user() - return '', 204 - + return dict(posts={f'{Snowflake(id):l}': pj}) diff --git a/freak/search.py b/freak/search.py index 6b357be..b4b7c27 100644 --- a/freak/search.py +++ b/freak/search.py @@ -2,8 +2,12 @@ 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] @@ -23,3 +27,24 @@ 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 90a3090..a7d0840 100644 --- a/freak/templates/about.html +++ b/freak/templates/about.html @@ -11,20 +11,20 @@

Stats

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

Software versions

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

License

-

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

+

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

{% if impressum %}

Legal Contacts

diff --git a/freak/templates/admin/admin_users.html b/freak/templates/admin/admin_users.html index d55d9f8..31079c4 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.user %} + {% if u == current_user %} (You) {% endif -%}

diff --git a/freak/templates/base.html b/freak/templates/base.html index 96a9916..a4eb90c 100644 --- a/freak/templates/base.html +++ b/freak/templates/base.html @@ -2,6 +2,7 @@ + {% from "macros/icon.html" import icon with context %} {% block title %} @@ -12,7 +13,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://{{ server_name }}/terms + More info: https://{{ domain_name }}/terms --> @@ -25,7 +26,7 @@ - +

{{ app_name }}

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