Compare commits

...

7 commits

39 changed files with 1453 additions and 947 deletions

View file

@ -1,5 +1,12 @@
# Changelog # 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 ## 0.4.0
- Added dependency to [SUOU](https://github.com/sakuragasaki46/suou) library - Added dependency to [SUOU](https://github.com/sakuragasaki46/suou) library

View file

@ -24,7 +24,7 @@
* Will to not give up. * Will to not give up.
* Clone this repository. * Clone this repository.
* Fill in `.env` with the necessary information. * Fill in `.env` with the necessary information.
* `DOMAIN_NAME` (see above) * `SERVER_NAME` (see above)
* `APP_NAME` * `APP_NAME`
* `DATABASE_URL` (hint: `postgresql://username:password@localhost/dbname`) * `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`) * `SECRET_KEY` (you can generate one with the command `cat /dev/random | tr -dc A-Za-z0-9_. | head -c 56`)

View file

@ -6,7 +6,7 @@ start-app() {
cp -rv /opt/live-app/{freak,pyproject.toml,docker-run.sh} ./ cp -rv /opt/live-app/{freak,pyproject.toml,docker-run.sh} ./
cp -v /opt/live-app/.env.prod .env cp -v /opt/live-app/.env.prod .env
pip install -e . pip install -e .
flask --app freak run --host=0.0.0.0 hypercorn freak:app -b 0.0.0.0:5000
} }
[[ "$1" = "" ]] && start-app [[ "$1" = "" ]] && start-app

View file

@ -1,30 +1,32 @@
import logging
import re import re
from sqlite3 import ProgrammingError from sqlite3 import ProgrammingError
import sys
from typing import Any from typing import Any
import warnings import warnings
from flask import ( from quart import (
Flask, g, redirect, render_template, Quart, flash, g, jsonify, redirect, render_template,
request, send_from_directory, url_for request, send_from_directory, url_for
) )
import os import os
import dotenv import dotenv
from flask_login import LoginManager from quart_auth import AuthUser, QuartAuth, Action as QA_Action, current_user
from flask_wtf.csrf import CSRFProtect from quart_wtf import CSRFProtect
from sqlalchemy import select from sqlalchemy import inspect, select
from sqlalchemy.exc import SQLAlchemyError
from suou import Snowflake, ssv_list from suou import Snowflake, ssv_list
from werkzeug.routing import BaseConverter from werkzeug.routing import BaseConverter
from sassutils.wsgi import SassMiddleware from suou.sass import SassAsyncMiddleware
from werkzeug.middleware.proxy_fix import ProxyFix from suou.quart import negotiate
from hypercorn.middleware import ProxyFixMiddleware
from suou.configparse import ConfigOptions, ConfigValue from suou.configparse import ConfigOptions, ConfigValue
from suou import twocolon_list, WantsContentType
from .colors import color_themes, theme_classes from .colors import color_themes, theme_classes
from .utils import twocolon_list
__version__ = '0.4.0' __version__ = '0.5.0-dev36'
APP_BASE_DIR = os.path.dirname(os.path.dirname(__file__)) APP_BASE_DIR = os.path.dirname(os.path.dirname(__file__))
@ -35,31 +37,42 @@ class AppConfig(ConfigOptions):
secret_key = ConfigValue(required=True) secret_key = ConfigValue(required=True)
database_url = ConfigValue(required=True) database_url = ConfigValue(required=True)
app_name = ConfigValue() app_name = ConfigValue()
domain_name = ConfigValue() server_name = ConfigValue()
private_assets = ConfigValue(cast=ssv_list) private_assets = ConfigValue(cast=ssv_list)
# deprecated
jquery_url = ConfigValue(default='https://ajax.googleapis.com/ajax/libs/jquery/3.7.1/jquery.min.js') jquery_url = ConfigValue(default='https://ajax.googleapis.com/ajax/libs/jquery/3.7.1/jquery.min.js')
app_is_behind_proxy = ConfigValue(cast=bool, default=False) app_is_behind_proxy = ConfigValue(cast=int, default=0)
impressum = ConfigValue(cast=twocolon_list, default='') impressum = ConfigValue(cast=twocolon_list, default='')
create_guild_threshold = ConfigValue(cast=int, default=15, prefix='freak_') create_guild_threshold = ConfigValue(cast=int, default=15, prefix='freak_')
app_config = AppConfig() app_config = AppConfig()
app = Flask(__name__) logging.basicConfig(level=logging.WARNING)
logger = logging.getLogger(__name__)
app = Quart(__name__)
app.secret_key = app_config.secret_key app.secret_key = app_config.secret_key
app.config['SQLALCHEMY_DATABASE_URI'] = app_config.database_url app.config['SQLALCHEMY_DATABASE_URI'] = app_config.database_url
app.config['JSONIFY_PRETTYPRINT_REGULAR'] = False app.config['JSONIFY_PRETTYPRINT_REGULAR'] = False
app.config['QUART_AUTH_DURATION'] = 365 * 24 * 60 * 60
app.config['SERVER_NAME'] = app_config.server_name
from .models import db, User, Post
## DO NOT ADD LOCAL IMPORTS BEFORE THIS LINE
from .accounts import UserLoader
from .models import Guild, db, User, Post
# SASS # SASS
app.wsgi_app = SassMiddleware(app.wsgi_app, dict( app.asgi_app = SassAsyncMiddleware(app.asgi_app, dict(
freak=('static/sass', 'static/css', '/static/css', True) freak=('static/sass', 'static/css', '/static/css', True)
)) ))
# proxy fix # proxy fix
if app_config.app_is_behind_proxy: if app_config.app_is_behind_proxy:
app.wsgi_app = ProxyFix( app.asgi_app = ProxyFixMiddleware(
app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1 app.asgi_app, trusted_hops=app_config.app_is_behind_proxy, mode='legacy'
) )
class SlugConverter(BaseConverter): class SlugConverter(BaseConverter):
@ -75,100 +88,169 @@ class B32lConverter(BaseConverter):
app.url_map.converters['slug'] = SlugConverter app.url_map.converters['slug'] = SlugConverter
app.url_map.converters['b32l'] = B32lConverter app.url_map.converters['b32l'] = B32lConverter
db.init_app(app) db.bind(app_config.database_url)
csrf = CSRFProtect(app) csrf = CSRFProtect(app)
login_manager = LoginManager(app)
login_manager.login_view = 'accounts.login'
# TODO configure quart_auth
login_manager = QuartAuth(app, user_class= UserLoader)
from . import filters from . import filters
PRIVATE_ASSETS = os.getenv('PRIVATE_ASSETS', '').split() PRIVATE_ASSETS = os.getenv('PRIVATE_ASSETS', '').split()
post_count_cache = 0
user_count_cache = 0
@app.context_processor @app.context_processor
def _inject_variables(): async def _inject_variables():
global post_count_cache, user_count_cache
try:
post_count = await Post.count()
user_count = await User.active_count()
except Exception as e:
logger.error(f'cannot compute post_count: {e}')
post_count = post_count_cache
user_count = user_count_cache
else:
post_count_cache = post_count
user_count_cache = user_count
return { return {
'app_name': app_config.app_name, 'app_name': app_config.app_name,
'app_version': __version__, 'app_version': __version__,
'domain_name': app_config.domain_name, 'server_name': app_config.server_name,
'url_for_css': (lambda name, **kwargs: url_for('static', filename=f'css/{name}.css', **kwargs)), '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_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')], 'private_styles': [x for x in PRIVATE_ASSETS if x.endswith('.css')],
'jquery_url': app_config.jquery_url, 'jquery_url': app_config.jquery_url,
'post_count': Post.count(), 'post_count': post_count,
'user_count': User.active_count(), 'user_count': user_count,
'colors': color_themes, 'colors': color_themes,
'theme_classes': theme_classes, 'theme_classes': theme_classes,
'impressum': '\n'.join(app_config.impressum).replace('_', ' ') 'impressum': '\n'.join(app_config.impressum).replace('_', ' ')
} }
@login_manager.user_loader @app.before_request
def _inject_user(userid): async def _load_user():
try: try:
u = db.session.execute(select(User).where(User.id == userid)).scalar() await current_user._load()
if u is None or u.is_disabled: except RuntimeError as e:
return None logger.error(f'{e}')
return u
except SQLAlchemyError as e:
warnings.warn(f'cannot retrieve user {userid} from db (exception: {e})', RuntimeWarning)
g.no_user = True g.no_user = True
return None
@app.after_request
async def _unload_user(resp):
try:
await current_user._unload()
except RuntimeError as e:
logger.error(f'{e}')
return resp
def redact_url_password(u: str | Any) -> str | Any: def redact_url_password(u: str | Any) -> str | Any:
if not isinstance(u, str): if not isinstance(u, str):
return u return u
return re.sub(r':[^@:/ ]+@', ':***@', 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) @app.errorhandler(ProgrammingError)
def error_db(body): async def error_db(body):
g.no_user = True g.no_user = True
warnings.warn(f'No database access! (url is {redact_url_password(app.config['SQLALCHEMY_DATABASE_URI'])!r})', RuntimeWarning) logger.error(f'No database access! (url is {redact_url_password(app.config['SQLALCHEMY_DATABASE_URI'])!r})', RuntimeWarning)
return render_template('500.html'), 500 return await error_handler_for(500, body, '500.html')
@app.errorhandler(400) @app.errorhandler(400)
def error_400(body): async def error_400(body):
return render_template('400.html'), 400 return await error_handler_for(400, body, '400.html')
@app.errorhandler(401)
async def error_401(body):
match negotiate():
case WantsContentType.HTML:
return redirect(url_for('accounts.login', next=request.path))
case _:
return await error_handler_for(401, 'Please log in.', 'login.html')
@app.errorhandler(403) @app.errorhandler(403)
def error_403(body): async def error_403(body):
return render_template('403.html'), 403 return await error_handler_for(403, body, '403.html')
from .search import find_guild_or_user async def find_guild_or_user(name: str) -> str | None:
"""
Used in 404 error handler.
Returns an URL to redirect or None for no redirect.
"""
if hasattr(g, 'no_user'):
return None
# do not execute for non-browsers_
if 'Mozilla/' not in request.user_agent.string:
return None
async with db as session:
gu = (await session.execute(select(Guild).where(Guild.name == name))).scalar()
user = (await session.execute(select(User).where(User.username == name))).scalar()
if gu is not None:
await flash(f'There is nothing at /{name}. Luckily, a guild with name {gu.handle()} happens to exist. Next time, remember to add + before!')
return gu.url()
if user is not None:
await flash(f'There is nothing at /{name}. Luckily, a user named {user.handle()} happens to exist. Next time, remember to add @ before!')
return user.url()
return None
@app.errorhandler(404) @app.errorhandler(404)
def error_404(body): async def error_404(body):
try: try:
if mo := re.match(r'/([a-z0-9_-]+)/?', request.path): if mo := re.match(r'/([a-z0-9_-]+)/?', request.path):
alternative = find_guild_or_user(mo.group(1)) alternative = await find_guild_or_user(mo.group(1))
if alternative is not None: if alternative is not None:
return redirect(alternative), 302 return redirect(alternative), 302
except Exception as e: except Exception as e:
warnings.warn(f'Exception in find_guild_or_user: {e}') logger.error(f'Exception in find_guild_or_user: {e}')
pass pass
return render_template('404.html'), 404 print(request.host)
return await error_handler_for(404, 'Not found', '404.html')
@app.errorhandler(405) @app.errorhandler(405)
def error_405(body): async def error_405(body):
return render_template('405.html'), 405 return await error_handler_for(405, body, '405.html')
@app.errorhandler(451) @app.errorhandler(451)
def error_451(body): async def error_451(body):
return render_template('451.html'), 451 return await error_handler_for(451, body, '451.html')
@app.errorhandler(500) @app.errorhandler(500)
def error_500(body): async def error_500(body):
g.no_user = True g.no_user = True
return render_template('500.html'), 500 return await error_handler_for(500, body, '500.html')
@app.route('/favicon.ico') @app.route('/favicon.ico')
def favicon_ico(): async def favicon_ico():
return send_from_directory(APP_BASE_DIR, 'favicon.ico') return await send_from_directory(APP_BASE_DIR, 'favicon.ico')
@app.route('/robots.txt') @app.route('/robots.txt')
def robots_txt(): async def robots_txt():
return send_from_directory(APP_BASE_DIR, 'robots.txt') return await send_from_directory(APP_BASE_DIR, 'robots.txt')
from .website import blueprints from .website import blueprints
@ -178,8 +260,8 @@ for bp in blueprints:
from .ajax import bp from .ajax import bp
app.register_blueprint(bp) app.register_blueprint(bp)
from .rest import rest_bp from .rest import bp
app.register_blueprint(rest_bp) app.register_blueprint(bp)

View file

@ -1,4 +1,6 @@
import asyncio
from .cli import main from .cli import main
main() asyncio.run(main())

83
freak/accounts.py Normal file
View file

@ -0,0 +1,83 @@
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

View file

@ -1,29 +1,35 @@
''' '''
AJAX hooks for the website. AJAX hooks for the OLD frontend.
2025 DEPRECATED in favor of /v1/ (REST) DEPRECATED in 0.5 in favor of /v1/ (REST)
''' '''
from __future__ import annotations
import re import re
from flask import Blueprint, abort, flash, redirect, request from quart import Blueprint, abort, flash, redirect, request
from sqlalchemy import delete, insert, select from sqlalchemy import delete, insert, select
from .models import Guild, Member, UserBlock, db, User, Post, PostUpvote, username_is_legal
from flask_login import current_user, login_required
current_user: User from freak import UserLoader
from freak.utils import get_request_form
from .models import Guild, Member, UserBlock, db, User, Post, PostUpvote, username_is_legal
from quart_auth import current_user, login_required
current_user: UserLoader
bp = Blueprint('ajax', __name__) bp = Blueprint('ajax', __name__)
@bp.route('/username_availability/<username>') @bp.route('/username_availability/<username>')
@bp.route('/ajax/username_availability/<username>') @bp.route('/ajax/username_availability/<username>')
def username_availability(username: str): async def username_availability(username: str):
is_valid = username_is_legal(username) is_valid = username_is_legal(username)
if is_valid: if is_valid:
user = db.session.execute(select(User).where(User.username == username)).scalar() async with db as session:
user = (await session.execute(select(User).where(User.username == username))).scalar()
is_available = user is None or user == current_user is_available = user is None or user == current_user.user
else: else:
is_available = False is_available = False
@ -34,13 +40,14 @@ def username_availability(username: str):
} }
@bp.route('/guild_name_availability/<name>') @bp.route('/guild_name_availability/<name>')
def guild_name_availability(name: str): async def guild_name_availability(name: str):
is_valid = username_is_legal(name) is_valid = username_is_legal(name)
if is_valid: if is_valid:
gd = db.session.execute(select(Guild).where(Guild.name == name)).scalar() async with db as session:
gd = (await session.execute(select(Guild).where(Guild.name == name))).scalar()
is_available = gd is None is_available = gd is None
else: else:
is_available = False is_available = False
@ -52,101 +59,112 @@ def guild_name_availability(name: str):
@bp.route('/comments/<b32l:id>/upvote', methods=['POST']) @bp.route('/comments/<b32l:id>/upvote', methods=['POST'])
@login_required @login_required
def post_upvote(id): async def post_upvote(id):
o = request.form['o'] form = await get_request_form()
p: Post | None = db.session.execute(select(Post).where(Post.id == id)).scalar() o = form['o']
async with db as session:
p: Post | None = (await session.execute(select(Post).where(Post.id == id))).scalar()
if p is None: if p is None:
return { 'status': 'fail', 'message': 'Post not found' }, 404 return { 'status': 'fail', 'message': 'Post not found' }, 404
if o == '1': cur_score = await p.upvoted_by(current_user.user)
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
db.session.commit() match (o, cur_score):
return { 'status': 'ok', 'count': p.upvotes() } case ('1', 0) | ('1', -1):
await session.execute(delete(PostUpvote).where(PostUpvote.c.post_id == p.id, PostUpvote.c.voter_id == current_user.id, PostUpvote.c.is_downvote == True))
await session.execute(insert(PostUpvote).values(post_id = p.id, voter_id = current_user.id, is_downvote = False))
case ('0', _):
await session.execute(delete(PostUpvote).where(PostUpvote.c.post_id == p.id, PostUpvote.c.voter_id == current_user.id))
case ('-1', 1) | ('-1', 0):
await session.execute(delete(PostUpvote).where(PostUpvote.c.post_id == p.id, PostUpvote.c.voter_id == current_user.id, PostUpvote.c.is_downvote == False))
await session.execute(insert(PostUpvote).values(post_id = p.id, voter_id = current_user.id, is_downvote = True))
case ('1', 1) | ('-1', -1):
pass
case _:
await session.rollback()
return { 'status': 'fail', 'message': 'Invalid score' }, 400
await session.commit()
return { 'status': 'ok', 'count': await p.upvotes() }
@bp.route('/@<username>/block', methods=['POST']) @bp.route('/@<username>/block', methods=['POST'])
@login_required @login_required
def block_user(username): async def block_user(username):
u = db.session.execute(select(User).where(User.username == username)).scalar() form = await get_request_form()
if u is None:
abort(404)
is_block = 'reverse' not in request.form
is_unblock = request.form.get('reverse') == '1'
if is_block: async with db as session:
if current_user.has_blocked(u): u = (await session.execute(select(User).where(User.username == username))).scalar()
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 return redirect(request.args.get('next', u.url())), 303
@bp.route('/+<name>/subscribe', methods=['POST']) @bp.route('/+<name>/subscribe', methods=['POST'])
@login_required @login_required
def subscribe_guild(name): async def subscribe_guild(name):
gu = db.session.execute(select(Guild).where(Guild.name == name)).scalar() form = await get_request_form()
if gu is None: async with db as session:
abort(404) gu = (await session.execute(select(Guild).where(Guild.name == name))).scalar()
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 gu is None:
abort(404)
is_join = 'reverse' not in form
is_leave = form.get('reverse') == '1'
if is_join: membership = (await session.execute(select(Member).where(Member.guild == gu, Member.user_id == current_user.id))).scalar()
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 is_join:
if membership is None: if membership is None:
return redirect(gu.url()), 303 membership = (await session.execute(insert(Member).values(
elif membership.is_subscribed == True: guild_id = gu.id,
membership.is_subscribed = False user_id = current_user.id,
db.session.add(membership) is_subscribed = True
else: ).returning(Member))).scalar()
return redirect(gu.url()), 303 elif membership.is_subscribed == False:
membership.is_subscribed = True
await session.add(membership)
else:
return redirect(gu.url()), 303
await flash(f"You are now subscribed to {gu.handle()}")
db.session.commit() if is_leave:
flash(f"Unsubscribed from {gu.handle()}.") if membership is None:
return redirect(gu.url()), 303
elif membership.is_subscribed == True:
membership.is_subscribed = False
await session.add(membership)
else:
return redirect(gu.url()), 303
await session.commit()
await flash(f"Unsubscribed from {gu.handle()}.")
return redirect(gu.url()), 303 return redirect(gu.url()), 303

View file

@ -2,15 +2,16 @@
from flask_login import current_user from flask_login import current_user
from sqlalchemy import and_, distinct, func, select from sqlalchemy import and_, distinct, func, select
from .models import Comment, Member, db, Post, Guild, User
current_user: User from .models import Comment, Member, Post, Guild, User
def cuser() -> User: def cuser() -> User:
return current_user if current_user.is_authenticated else None return current_user.user if current_user else None
def cuser_id() -> int: def cuser_id() -> int:
return current_user.id if current_user.is_authenticated else None return current_user.id if current_user else None
def public_timeline(): def public_timeline():
return select(Post).join(User, User.id == Post.author_id).where( return select(Post).join(User, User.id == Post.author_id).where(
@ -18,24 +19,25 @@ def public_timeline():
).order_by(Post.created_at.desc()) ).order_by(Post.created_at.desc())
def topic_timeline(gname): def topic_timeline(gname):
return select(Post).join(Guild).join(User, User.id == Post.author_id).where( return select(Post).join(Guild, Guild.id == Post.topic_id).join(User, User.id == Post.author_id).where(
Post.privacy == 0, Guild.name == gname, User.not_suspended(), Post.not_removed(), User.has_not_blocked(Post.author_id, cuser_id()) 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()) ).order_by(Post.created_at.desc())
def user_timeline(user_id): def user_timeline(user: User):
return select(Post).join(User, User.id == Post.author_id).where( return select(Post).join(User, User.id == Post.author_id).where(
Post.visible_by(cuser_id()), User.id == user_id, User.not_suspended(), Post.not_removed(), User.has_not_blocked(Post.author_id, cuser_id()) Post.visible_by(cuser_id()), Post.author_id == user.id, User.not_suspended(), Post.not_removed(), User.has_not_blocked(Post.author_id, cuser_id())
).order_by(Post.created_at.desc()) ).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): def new_comments(p: Post):
return select(Comment).where(Comment.parent_post_id == p.id, Comment.parent_comment_id == None, return select(Comment).join(Post, Post.id == Comment.parent_post_id).join(User, User.id == Comment.author_id).where(Comment.parent_post_id == p.id, Comment.parent_comment_id == None,
Comment.not_removed(), User.has_not_blocked(Comment.author_id, cuser_id())).order_by(Comment.created_at.desc()) 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

View file

@ -6,7 +6,7 @@ import subprocess
from sqlalchemy import create_engine, select from sqlalchemy import create_engine, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from . import __version__ as version, app from . import __version__ as version, app_config
from .models import User, db from .models import User, db
def make_parser(): def make_parser():
@ -16,7 +16,7 @@ def make_parser():
parser.add_argument('--flush', '-H', action='store_true', help='recompute karma for all users') parser.add_argument('--flush', '-H', action='store_true', help='recompute karma for all users')
return parser return parser
def main(): async def main():
args = make_parser().parse_args() args = make_parser().parse_args()
engine = create_engine(os.getenv('DATABASE_URL')) engine = create_engine(os.getenv('DATABASE_URL'))
@ -26,18 +26,19 @@ def main():
print(f'Schema upgrade failed (code: {ret_code})') print(f'Schema upgrade failed (code: {ret_code})')
exit(ret_code) exit(ret_code)
# if the alembic/versions folder is empty # if the alembic/versions folder is empty
db.metadata.create_all(engine) await db.create_all(engine)
print('Schema upgraded!') print('Schema upgraded!')
if args.flush: if args.flush:
cnt = 0 cnt = 0
with app.app_context(): async with db as session:
for u in db.session.execute(select(User)).scalars():
for u in (await session.execute(select(User))).scalars():
u.recompute_karma() u.recompute_karma()
cnt += 1 cnt += 1
db.session.add(u) session.add(u)
db.session.commit() session.commit()
print(f'Recomputed karma of {cnt} users') print(f'Recomputed karma of {cnt} users')
print(f'Visit <https://{os.getenv("DOMAIN_NAME")}>') print(f'Visit <https://{app_config.server_name}>')

View file

@ -21,7 +21,7 @@ color_themes = [
ColorTheme(10, 'Defoko'), ColorTheme(10, 'Defoko'),
ColorTheme(11, 'Kaito'), ColorTheme(11, 'Kaito'),
ColorTheme(12, 'Meiko'), ColorTheme(12, 'Meiko'),
ColorTheme(13, 'Leek'), ColorTheme(13, 'WhatsApp'),
ColorTheme(14, 'Teto'), ColorTheme(14, 'Teto'),
ColorTheme(15, 'Ruby') ColorTheme(15, 'Ruby')
] ]

View file

@ -1,77 +0,0 @@
"""
Utilities for Diversity, Equity, Inclusion
"""
from __future__ import annotations
BRICKS = '@abcdefghijklmnopqrstuvwxyz+?-\'/'
# legend @: space, -: literal, +: suffix (i.e. ae+r expands to ae/aer), ': literal, ?: unknown, /: separator
class Pronoun(int):
PRESETS = {
'hh': 'he/him',
'sh': 'she/her',
'tt': 'they/them',
'ii': 'it/its',
'hs': 'he/she',
'ht': 'he/they',
'hi': 'he/it',
'shh': 'she/he',
'st': 'she/they',
'si': 'she/it',
'th': 'they/he',
'ts': 'they/she',
'ti': 'they/it',
}
UNSPECIFIED = 0
## presets from PronounDB
## DO NOT TOUCH the values unless you know their exact correspondence!!
## hint: Pronoun.from_short()
HE = HE_HIM = 264
SHE = SHE_HER = 275
THEY = THEY_THEM = 660
IT = IT_ITS = 297
HE_SHE = 616
HE_THEY = 648
HE_IT = 296
SHE_HE = 8467
SHE_THEY = 657
SHE_IT = 307
THEY_HE = 276
THEY_SHE = 628
THEY_IT = 308
ANY = 26049
OTHER = 19047055
ASK = 11873
AVOID = NAME_ONLY = 4505281
def short(self) -> str:
i = self
s = ''
while i > 0:
s += BRICKS[i % 32]
i >>= 5
return s
def full(self):
s = self.short()
if s in self.PRESETS:
return self.PRESETS[s]
if '+' in s:
s1, s2 = s.rsplit('+')
s = s1 + '/' + s1 + s2
return s
__str__ = full
@classmethod
def from_short(self, s: str) -> Pronoun:
i = 0
for j, ch in enumerate(s):
i += BRICKS.index(ch) << (5 * j)
return Pronoun(i)

View file

@ -8,22 +8,29 @@ from functools import partial
from operator import or_ from operator import or_
import re import re
from threading import Lock from threading import Lock
from sqlalchemy import Column, Integer, String, ForeignKey, UniqueConstraint, insert, text, \ from typing import Any, Callable
from quart_auth import current_user
from sqlalchemy import Column, Integer, String, ForeignKey, UniqueConstraint, and_, insert, text, \
CheckConstraint, Date, DateTime, Boolean, func, BigInteger, \ CheckConstraint, Date, DateTime, Boolean, func, BigInteger, \
SmallInteger, select, update, Table SmallInteger, select, update, Table
from sqlalchemy.orm import Relationship, relationship from sqlalchemy.orm import Relationship, relationship
from flask_sqlalchemy import SQLAlchemy from suou.sqlalchemy_async import SQLAlchemy
from flask_login import AnonymousUserMixin from suou import SiqType, Snowflake, Wanted, deprecated, makelist, not_implemented
from suou import SiqType, Snowflake, Wanted, deprecated, not_implemented
from suou.sqlalchemy import create_session, declarative_base, id_column, parent_children, snowflake_column from suou.sqlalchemy import create_session, declarative_base, id_column, parent_children, snowflake_column
from werkzeug.security import check_password_hash from werkzeug.security import check_password_hash
from freak import app_config from . import app_config
from .utils import age_and_days, get_remote_addr, timed_cache from .utils import get_remote_addr
from suou import timed_cache, age_and_days
import logging
logger = logging.getLogger(__name__)
## Constants and enums ## Constants and enums
## NOT IN USE: User has .banned_at and .is_disabled_by_user
USER_ACTIVE = 0 USER_ACTIVE = 0
USER_INACTIVE = 1 USER_INACTIVE = 1
USER_BANNED = 2 USER_BANNED = 2
@ -71,16 +78,16 @@ ILLEGAL_USERNAMES = tuple((
'me everyone here room all any server app dev devel develop nil none ' 'me everyone here room all any server app dev devel develop nil none '
'founder owner admin administrator mod modteam moderator sysop some ' 'founder owner admin administrator mod modteam moderator sysop some '
## fictitious users and automations ## fictitious users and automations
'nobody deleted suspended default bot developer undefined null ' 'nobody somebody deleted suspended default bot developer undefined null '
'ai automod automoderator assistant privacy anonymous removed assistance ' 'ai automod clanker automoderator assistant privacy anonymous removed assistance '
## law enforcement corps and slurs because yes ## law enforcement corps and slurs because yes
'pedo rape rapist nigger retard ncmec police cops 911 childsafety ' 'pedo rape rapist nigger retard ncmec police cops 911 childsafety '
'report dmca login logout security order66 gestapo ss hitler heilhitler kgb ' 'report dmca login logout security order66 gestapo ss hitler heilhitler kgb '
'pedophile lolicon giphy tenor csam cp pedobear lolita lolice thanos ' 'pedophile lolicon giphy tenor csam cp pedobear lolita lolice thanos '
'loli kkk pnf adl cop tranny google trustandsafety safety ice fbi nsa it ' 'loli lolicon kkk pnf adl cop tranny google trustandsafety safety ice fbi nsa it '
## VVVVIP ## VVVVIP
'potus realdonaldtrump elonmusk teddysphotos mrbeast jkrowling pewdiepie ' 'potus realdonaldtrump elonmusk teddysphotos mrbeast jkrowling pewdiepie '
'elizabethii king queen pontifex hogwarts lumos alohomora isis daesh ' 'elizabethii elizabeth2 king queen pontifex hogwarts lumos alohomora isis daesh retards '
).split()) ).split())
def username_is_legal(username: str) -> bool: def username_is_legal(username: str) -> bool:
@ -94,21 +101,26 @@ def username_is_legal(username: str) -> bool:
return False return False
return True 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 ## END constants and enums
Base = declarative_base(app_config.domain_name, app_config.secret_key, Base = declarative_base(app_config.server_name, app_config.secret_key,
snowflake_epoch=1577833200) snowflake_epoch=1577833200)
db = SQLAlchemy(model_class=Base) db = SQLAlchemy(model_class=Base)
CSI = create_session_interactively = partial(create_session, app_config.database_url) CSI = create_session_interactively = partial(create_session, app_config.database_url)
# the BaseModel() class will be removed in 0.5 ## .accounts requires db
from .iding import new_id #current_user: UserLoader
@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 ## Many-to-many relationship keys for some reasons have to go
## BEFORE other table definitions. ## BEFORE other table definitions.
@ -151,6 +163,7 @@ class User(Base):
karma = Column(BigInteger, server_default=text('0'), nullable=False) karma = Column(BigInteger, server_default=text('0'), nullable=False)
legacy_id = Column(BigInteger, nullable=True) 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) pronouns = Column(Integer, server_default=text('0'), nullable=False)
biography = Column(String(1024), nullable=True) biography = Column(String(1024), nullable=True)
color_theme = Column(SmallInteger, nullable=False, server_default=text('0')) color_theme = Column(SmallInteger, nullable=False, server_default=text('0'))
@ -171,8 +184,8 @@ class User(Base):
## SQLAlchemy fail initialization of models — bricking the app. ## SQLAlchemy fail initialization of models — bricking the app.
## Posts are queried manually anyway ## Posts are queried manually anyway
#posts = relationship("Post", primaryjoin=lambda: #back_populates='author', pr) #posts = relationship("Post", primaryjoin=lambda: #back_populates='author', pr)
upvoted_posts = relationship("Post", secondary=PostUpvote, back_populates='upvoters') upvoted_posts = relationship("Post", secondary=PostUpvote, back_populates='upvoters', lazy='selectin')
#comments = relationship("Comment", back_populates='author') #comments = relationship("Comment", back_populates='author', lazy='selectin')
@property @property
def is_disabled(self): def is_disabled(self):
@ -189,13 +202,16 @@ class User(Base):
return not self.is_disabled return not self.is_disabled
@property @property
@deprecated('shadowed by UserLoader.is_authenticated(), and always true')
def is_authenticated(self): def is_authenticated(self):
return True return True
@property @property
@deprecated('no more in use since switch to Quart')
def is_anonymous(self): def is_anonymous(self):
return False return False
@deprecated('this representation uses decimal, URLs use b32l')
def get_id(self): def get_id(self):
return str(self.id) return str(self.id)
@ -206,26 +222,32 @@ class User(Base):
def age(self): def age(self):
return age_and_days(self.gdpr_birthday)[0] return age_and_days(self.gdpr_birthday)[0]
def simple_info(self): def simple_info(self, *, typed = False):
""" """
Return essential informations for representing a user in the REST Return essential informations for representing a user in the REST
""" """
## XXX change func name? ## XXX change func name?
return dict( gg = dict(
id = Snowflake(self.id).to_b32l(), id = Snowflake(self.id).to_b32l(),
username = self.username, username = self.username,
display_name = self.display_name, display_name = self.display_name,
age = self.age() age = self.age(),
## TODO add badges? badges = self.badges(),
)
def reward(self, points=1): )
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):
""" """
Manipulate a user's karma on the fly Manipulate a user's karma on the fly
""" """
with Lock(): with Lock():
db.session.execute(update(User).where(User.id == self.id).values(karma = self.karma + points)) async with db as session:
db.session.commit() await session.execute(update(User).where(User.id == self.id).values(karma = self.karma + points))
await session.commit()
def can_create_guild(self): def can_create_guild(self):
## TODO make guild creation requirements fully configurable ## TODO make guild creation requirements fully configurable
@ -240,10 +262,12 @@ class User(Base):
return check_password_hash(self.passhash, password) return check_password_hash(self.passhash, password)
@classmethod @classmethod
@timed_cache(1800) @timed_cache(1800, async_=True)
def active_count(cls) -> int: async def active_count(cls) -> int:
active_th = datetime.datetime.now() - datetime.timedelta(days=30) active_th = datetime.datetime.now() - datetime.timedelta(days=30)
return db.session.execute(select(func.count(User.id)).select_from(cls).join(Post, Post.author_id == User.id).where(Post.created_at >= active_th).group_by(User.id)).scalar() async with db as session:
count = (await session.execute(select(func.count(User.id)).select_from(cls).join(Post, Post.author_id == User.id).where(Post.created_at >= active_th).group_by(User.id))).scalar()
return count
def __repr__(self): def __repr__(self):
return f'<{self.__class__.__name__} id:{self.id!r} username:{self.username!r}>' return f'<{self.__class__.__name__} id:{self.id!r} username:{self.username!r}>'
@ -252,10 +276,25 @@ class User(Base):
def not_suspended(cls): def not_suspended(cls):
return or_(User.banned_at == None, User.banned_until <= datetime.datetime.now()) return or_(User.banned_at == None, User.banned_until <= datetime.datetime.now())
def has_blocked(self, other: User | None) -> bool: async def has_blocked(self, other: User | None) -> bool:
if other is None or not other.is_authenticated: if not want_User(other, var_name='other', prefix='User.has_blocked()'):
return False return False
return bool(db.session.execute(select(UserBlock).where(UserBlock.c.actor_id == self.id, UserBlock.c.target_id == other.id)).scalar()) async with db as session:
block_exists = (await session.execute(select(UserBlock).where(UserBlock.c.actor_id == self.id, UserBlock.c.target_id == other.id))).scalar()
return bool(block_exists)
async def is_blocked_by(self, other: User | None) -> bool:
if not want_User(other, var_name='other', prefix='User.is_blocked_by()'):
return False
async with db as session:
block_exists = (await session.execute(select(UserBlock).where(UserBlock.c.actor_id == other.id, UserBlock.c.target_id == self.id))).scalar()
return bool(block_exists)
def has_blocked_q(self, other_id: int):
return select(UserBlock).where(UserBlock.c.actor_id == self.id, UserBlock.c.target_id == other_id).exists()
def blocked_by_q(self, other_id: int):
return select(UserBlock).where(UserBlock.c.actor_id == other_id, UserBlock.c.target_id == self.id).exists()
@not_implemented() @not_implemented()
def end_friendship(self, other: User): def end_friendship(self, other: User):
@ -268,10 +307,10 @@ class User(Base):
def has_subscriber(self, other: User) -> bool: def has_subscriber(self, other: User) -> bool:
# TODO implement in 0.5 # TODO implement in 0.5
return False #bool(db.session.execute(select(Friendship).where(...)).scalar()) return False #bool(session.execute(select(Friendship).where(...)).scalar())
@classmethod @classmethod
def has_not_blocked(cls, actor, target): def has_not_blocked(cls, actor: int, target: int):
""" """
Filter out a content if the author has blocked current user. Returns a query. Filter out a content if the author has blocked current user. Returns a query.
@ -285,33 +324,64 @@ class User(Base):
qq= ~select(UserBlock).where(UserBlock.c.actor_id == actor_id, UserBlock.c.target_id == target_id).exists() qq= ~select(UserBlock).where(UserBlock.c.actor_id == actor_id, UserBlock.c.target_id == target_id).exists()
return qq return qq
def recompute_karma(self): async def recompute_karma(self):
c = 0 """
c += db.session.execute(select(func.count('*')).select_from(Post).where(Post.author == self)).scalar() Recompute karma as of 0.4.0 karma handling
c += db.session.execute(select(func.count('*')).select_from(PostUpvote).join(Post).where(Post.author == self, PostUpvote.c.is_downvote == False)).scalar() """
c -= db.session.execute(select(func.count('*')).select_from(PostUpvote).join(Post).where(Post.author == self, PostUpvote.c.is_downvote == True)).scalar() async with db as session:
c = 0
c += session.execute(select(func.count('*')).select_from(Post).where(Post.author == self)).scalar()
c += session.execute(select(func.count('*')).select_from(PostUpvote).join(Post).where(Post.author == self, PostUpvote.c.is_downvote == False)).scalar()
c -= session.execute(select(func.count('*')).select_from(PostUpvote).join(Post).where(Post.author == self, PostUpvote.c.is_downvote == True)).scalar()
self.karma = c
self.karma = c return c
@timed_cache(60) ## TODO are coroutines cacheable?
def strike_count(self) -> int: @timed_cache(60, async_=True)
return db.session.execute(select(func.count('*')).select_from(UserStrike).where(UserStrike.user_id == self.id)).scalar() async def strike_count(self) -> int:
async with db as session:
return (await session.execute(select(func.count('*')).select_from(UserStrike).where(UserStrike.user_id == self.id))).scalar()
def moderates(self, gu: Guild) -> bool: async def moderates(self, gu: Guild) -> bool:
## owner async with db as session:
if gu.owner_id == self.id: ## owner
return True if gu.owner_id == self.id:
## admin or global mod return True
if self.is_administrator: ## admin or global mod
return True if self.is_administrator:
memb = db.session.execute(select(Member).where(Member.user_id == self.id, Member.guild_id == gu.id)).scalar() return True
memb = (await session.execute(select(Member).where(Member.user_id == self.id, Member.guild_id == gu.id))).scalar()
if memb is None: if memb is None:
return False return False
return memb.is_moderator return memb.is_moderator
## TODO check banship? ## 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 !! # UserBlock table is at the top !!
## END User ## END User
@ -346,63 +416,92 @@ class Guild(Base):
def handle(self): def handle(self):
return f'+{self.name}' return f'+{self.name}'
def subscriber_count(self): async def subscriber_count(self):
return db.session.execute(select(func.count('*')).select_from(Member).where(Member.guild == self, Member.is_subscribed == True)).scalar() async with db as session:
count = (await session.execute(select(func.count('*')).select_from(Member).where(Member.guild == self, Member.is_subscribed == True))).scalar()
return count
# utilities # utilities
owner = relationship(User, foreign_keys=owner_id) owner = relationship(User, foreign_keys=owner_id, lazy='selectin')
posts = relationship('Post', back_populates='guild') posts = relationship('Post', back_populates='guild', lazy='selectin')
def has_subscriber(self, other: User) -> bool: async def post_count(self):
if other is None or not other.is_authenticated: async with db as session:
return False return (await session.execute(select(func.count('*')).select_from(Post).where(Post.guild == self))).scalar()
return bool(db.session.execute(select(Member).where(Member.user_id == other.id, Member.guild_id == self.id, Member.is_subscribed == True)).scalar())
def has_exiled(self, other: User) -> bool: async def has_subscriber(self, other: User) -> bool:
if other is None or not other.is_authenticated: if not want_User(other, var_name='other', prefix='Guild.has_subscriber()'):
return False return False
u = db.session.execute(select(Member).where(Member.user_id == other.id, Member.guild_id == self.id)).scalar() async with db as session:
sub_ex = (await session.execute(select(Member).where(Member.user_id == other.id, Member.guild_id == self.id, Member.is_subscribed == True))).scalar()
return bool(sub_ex)
async def has_exiled(self, other: User) -> bool:
if not want_User(other, var_name='other', prefix='Guild.has_exiled()'):
return False
async with db as session:
u = (await session.execute(select(Member).where(Member.user_id == other.id, Member.guild_id == self.id))).scalar()
return u.is_banned if u else False return u.is_banned if u else False
def allows_posting(self, other: User) -> bool: async def allows_posting(self, other: User) -> bool:
if self.owner is None: async with db as session:
return False # control owner_id instead of owner: the latter causes MissingGreenletError
if other.is_disabled: if self.owner_id is None:
return False 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 other.is_disabled:
if mem and mem.is_banned: return False
return False mem: Member | None = (await session.execute(select(Member).where(Member.user_id == other.id, Member.guild_id == self.id))).scalar()
if other.moderates(self): if mem and mem.is_banned:
return False
if await other.moderates(self):
return True
if self.is_restricted:
return (mem and mem.is_approved)
return True return True
if self.is_restricted:
return (mem and mem.is_approved)
return True
async def moderators(self):
def moderators(self): async with db as session:
if self.owner: if self.owner_id:
yield ModeratorInfo(self.owner, True) owner = (await session.execute(select(User).where(User.id == self.owner_id))).scalar()
for mem in db.session.execute(select(Member).where(Member.guild_id == self.id, Member.is_moderator == True)).scalars(): yield ModeratorInfo(owner, True)
if mem.user != self.owner and not mem.is_banned: for mem in (await session.execute(select(Member).where(Member.guild_id == self.id, Member.is_moderator == True))).scalars():
yield ModeratorInfo(mem.user, False) if mem.user != self.owner and not mem.is_banned:
yield ModeratorInfo(mem.user, False)
def update_member(self, u: User | Member, /, **values): async def update_member(self, u: User | Member, /, **values):
if isinstance(u, User): if isinstance(u, User):
m = db.session.execute(select(Member).where(Member.user_id == u.id, Member.guild_id == self.id)).scalar() async with db as session:
if m is None: m = (await session.execute(select(Member).where(Member.user_id == u.id, Member.guild_id == self.id))).scalar()
m = db.session.execute(insert(Member).values(
guild_id = self.id,
user_id = u.id,
**values
).returning(Member)).scalar()
if m is None: if m is None:
raise RuntimeError m = (await session.execute(insert(Member).values(
return m guild_id = self.id,
user_id = u.id,
**values
).returning(Member))).scalar()
if m is None:
raise RuntimeError
return m
else: else:
m = u m = u
if len(values): if len(values):
db.session.execute(update(Member).where(Member.user_id == u.id, Member.guild_id == self.id).values(**values)) async with db as session:
session.execute(update(Member).where(Member.user_id == u.id, Member.guild_id == self.id).values(**values))
return m 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) Topic = deprecated('renamed to Guild')(Guild)
@ -433,9 +532,9 @@ class Member(Base):
banned_until = Column(DateTime, nullable=True) banned_until = Column(DateTime, nullable=True)
banned_message = Column(String(256), nullable=True) banned_message = Column(String(256), nullable=True)
user = relationship(User, primaryjoin = lambda: User.id == Member.user_id) user = relationship(User, primaryjoin = lambda: User.id == Member.user_id, lazy='selectin')
guild = relationship(Guild) guild = relationship(Guild, lazy='selectin')
banned_by = relationship(User, primaryjoin = lambda: User.id == Member.banned_by_id) banned_by = relationship(User, primaryjoin = lambda: User.id == Member.banned_by_id, lazy='selectin')
@property @property
def is_banned(self): def is_banned(self):
@ -474,10 +573,14 @@ class Post(Base):
removed_reason = Column(SmallInteger, nullable=True) removed_reason = Column(SmallInteger, nullable=True)
# utilities # utilities
author: Relationship[User] = relationship("User", lazy='selectin', foreign_keys=[author_id])#, back_populates="posts") author: Relationship[User] = relationship("User", foreign_keys=[author_id], lazy='selectin')#, back_populates="posts")
guild: Relationship[Guild] = relationship("Guild", back_populates="posts", lazy='selectin') guild: Relationship[Guild] = relationship("Guild", back_populates="posts", lazy='selectin')
comments = relationship("Comment", back_populates="parent_post") comments = relationship("Comment", back_populates="parent_post", lazy='selectin')
upvoters = relationship("User", secondary=PostUpvote, back_populates='upvoted_posts') upvoters = relationship("User", secondary=PostUpvote, back_populates='upvoted_posts', lazy='selectin')
async def comment_count(self):
async with db as session:
return (await session.execute(select(func.count('*')).select_from(Comment).where(Comment.parent_post == self))).scalar()
def topic_or_user(self) -> Guild | User: def topic_or_user(self) -> Guild | User:
return self.guild or self.author return self.guild or self.author
@ -489,33 +592,41 @@ class Post(Base):
def generate_slug(self) -> str: def generate_slug(self) -> str:
return "slugify.slugify(self.title, max_length=64)" return "slugify.slugify(self.title, max_length=64)"
def upvotes(self) -> int: async 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() async with db as session:
- db.session.execute(select(func.count('*')).select_from(PostUpvote).where(PostUpvote.c.post_id == self.id, PostUpvote.c.is_downvote == True)).scalar()) upv = (await session.execute(select(func.count('*')).select_from(PostUpvote).where(PostUpvote.c.post_id == self.id, PostUpvote.c.is_downvote == False))).scalar()
dwv = (await session.execute(select(func.count('*')).select_from(PostUpvote).where(PostUpvote.c.post_id == self.id, PostUpvote.c.is_downvote == True))).scalar()
return upv - dwv
def upvoted_by(self, user: User | AnonymousUserMixin | None): async def upvoted_by(self, user: User | None):
if not user or not user.is_authenticated: if not want_User(user, var_name='user', prefix='Post.upvoted_by()'):
return 0 return 0
v: PostUpvote | None = db.session.execute(select(PostUpvote.c).where(PostUpvote.c.voter_id == user.id, PostUpvote.c.post_id == self.id)).fetchone() async with db as session:
if v: 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_downvote: if v is None:
return 0
if v == (True,):
return -1 return -1
return 1 if v == (False,):
return 0 return 1
logger.warning(f'unexpected value: {v}')
return 0
def top_level_comments(self, limit=None): async def top_level_comments(self, limit=None):
return db.session.execute(select(Comment).where(Comment.parent_comment == None, Comment.parent_post == self).order_by(Comment.created_at.desc()).limit(limit)).scalars() async with db as session:
return (await session.execute(select(Comment).where(Comment.parent_comment == None, Comment.parent_post == self).order_by(Comment.created_at.desc()).limit(limit))).scalars()
def report_url(self) -> str: def report_url(self) -> str:
return f'/report/post/{Snowflake(self.id):l}' return f'/report/post/{Snowflake(self.id):l}'
def report_count(self) -> int: async def report_count(self) -> int:
return db.session.execute(select(func.count('*')).select_from(PostReport).where(PostReport.target_id == self.id, ~PostReport.update_status.in_((1, 2)))).scalar() async with db as session: return (await session.execute(select(func.count('*')).select_from(PostReport).where(PostReport.target_id == self.id, ~PostReport.update_status.in_((1, 2))))).scalar()
@classmethod @classmethod
@timed_cache(1800) @timed_cache(1800, async_=True)
def count(cls): async def count(cls):
return db.session.execute(select(func.count('*')).select_from(cls)).scalar() async with db as session:
return (await session.execute(select(func.count('*')).select_from(cls))).scalar()
@property @property
def is_removed(self) -> bool: def is_removed(self) -> bool:
@ -527,8 +638,21 @@ class Post(Base):
@classmethod @classmethod
def visible_by(cls, user_id: int | None): def visible_by(cls, user_id: int | None):
return or_(Post.author_id == user_id, Post.privacy.in_((0, 1))) return or_(Post.author_id == user_id, Post.privacy == 0)
#return or_(Post.author_id == user_id, and_(Post.privacy.in_((0, 1)), ~Post.author.has_blocked_q(user_id)))
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): class Comment(Base):
__tablename__ = 'freak_comment' __tablename__ = 'freak_comment'
@ -554,8 +678,8 @@ class Comment(Base):
removed_by_id = Column(BigInteger, ForeignKey('freak_user.id', name='user_banner_id'), nullable=True) removed_by_id = Column(BigInteger, ForeignKey('freak_user.id', name='user_banner_id'), nullable=True)
removed_reason = Column(SmallInteger, nullable=True) removed_reason = Column(SmallInteger, nullable=True)
author = relationship('User', foreign_keys=[author_id])#, back_populates='comments') 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]) parent_post: Relationship[Post] = relationship("Post", back_populates="comments", foreign_keys=[parent_post_id], lazy='selectin')
parent_comment, child_comments = parent_children('comment', parent_remote_side=Wanted('id')) parent_comment, child_comments = parent_children('comment', parent_remote_side=Wanted('id'))
def url(self): def url(self):
@ -564,8 +688,9 @@ class Comment(Base):
def report_url(self) -> str: def report_url(self) -> str:
return f'/report/comment/{Snowflake(self.id):l}' return f'/report/comment/{Snowflake(self.id):l}'
def report_count(self) -> int: async def report_count(self) -> int:
return db.session.execute(select(func.count('*')).select_from(PostReport).where(PostReport.target_id == self.id, ~PostReport.update_status.in_((1, 2)))).scalar() async with db as session:
return (await session.execute(select(func.count('*')).select_from(PostReport).where(PostReport.target_id == self.id, ~PostReport.update_status.in_((1, 2))))).scalar()
@property @property
def is_removed(self) -> bool: def is_removed(self) -> bool:
@ -588,15 +713,16 @@ class PostReport(Base):
created_at = Column(DateTime, server_default=func.current_timestamp()) created_at = Column(DateTime, server_default=func.current_timestamp())
created_ip = Column(String(64), default=get_remote_addr, nullable=False) created_ip = Column(String(64), default=get_remote_addr, nullable=False)
author = relationship('User') author = relationship('User', lazy='selectin')
def target(self): async def target(self):
if self.target_type == REPORT_TARGET_POST: async with db as session:
return db.session.execute(select(Post).where(Post.id == self.target_id)).scalar() if self.target_type == REPORT_TARGET_POST:
elif self.target_type == REPORT_TARGET_COMMENT: return (await session.execute(select(Post).where(Post.id == self.target_id))).scalar()
return db.session.execute(select(Comment).where(Comment.id == self.target_id)).scalar() elif self.target_type == REPORT_TARGET_COMMENT:
else: return (await session.execute(select(Comment).where(Comment.id == self.target_id))).scalar()
return self.target_id else:
return self.target_id
def is_critical(self): def is_critical(self):
return self.reason_code in ( return self.reason_code in (
@ -616,9 +742,10 @@ class UserStrike(Base):
issued_at = Column(DateTime, server_default=func.current_timestamp()) issued_at = Column(DateTime, server_default=func.current_timestamp())
issued_by_id = Column(BigInteger, ForeignKey('freak_user.id'), nullable=True) issued_by_id = Column(BigInteger, ForeignKey('freak_user.id'), nullable=True)
user = relationship(User, primaryjoin= lambda: User.id == UserStrike.user_id) user = relationship(User, primaryjoin= lambda: User.id == UserStrike.user_id, lazy='selectin')
issued_by = relationship(User, primaryjoin= lambda: User.id == UserStrike.issued_by_id) issued_by = relationship(User, primaryjoin= lambda: User.id == UserStrike.issued_by_id, lazy='selectin')
# PostUpvote table is at the top !! # PostUpvote table is at the top !!

View file

@ -1,68 +1,224 @@
from __future__ import annotations
from flask import Blueprint, redirect, url_for from flask import abort
from flask_restx import Resource 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 sqlalchemy import select from sqlalchemy import select
from suou import Snowflake 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.flask_sqlalchemy import require_auth from suou.flask_sqlalchemy import require_auth
auth_required = deprecated('use login_required() and current_user instead')(require_auth(User, db))
from suou.flask_restx import Api @not_implemented()
async def authenticated():
pass
from ..models import Post, User, db @bp.get('/nurupo')
async def get_nurupo():
return dict(ga=-1)
rest_bp = Blueprint('rest', __name__, url_prefix='/v1') @bp.get('/health')
rest = Api(rest_bp) 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
)
auth_required = require_auth(User, db) return hi
@rest.route('/nurupo') @bp.get('/oath')
class Nurupo(Resource): async def oath():
def get(self): return dict(
return dict(nurupo='ga') ## XXX might break any time!
csrf_token= await csrf._get_csrf_token()
)
## TODO coverage of REST is still partial, but it's planned ## TODO coverage of REST is still partial, but it's planned
## to get complete sooner or later ## to get complete sooner or later
## XXX there is a bug in suou.sqlalchemy.auth_required() — apparently, /user/@me does not ## XXX there is a bug in suou.sqlalchemy.auth_required() — apparently, /user/@me does not
## redirect, neither is able to get user injected. ## redirect, neither is able to get user injected. It was therefore dismissed.
## Auth-based REST endpoints won't be fully functional until 0.6 in most cases ## Auth-based REST endpoints won't be fully functional until 0.6 in most cases
@rest.route('/user/@me') ## USERS ##
class UserInfoMe(Resource):
@auth_required(required=True)
def get(self, user: User):
return redirect(url_for('rest.UserInfo', user.id)), 302
@rest.route('/user/<b32l:id>') @bp.get('/user/@me')
class UserInfo(Resource): @login_required
def get(self, id: int): async def get_user_me():
## TODO sanizize REST to make blocked users inaccessible return redirect(url_for(f'rest.user_get', id=current_user.id)), 302
u: User | None = db.session.execute(select(User).where(User.id == id)).scalar()
if u is None: def _user_info(u: User):
return dict(error='User not found'), 404 return dict(
uj = dict(
id = f'{Snowflake(u.id):l}', id = f'{Snowflake(u.id):l}',
username = u.username, username = u.username,
display_name = u.display_name, display_name = u.display_name,
joined_at = u.joined_at.isoformat('T'), joined_at = want_isodate(u.joined_at),
karma = u.karma, karma = u.karma,
age = u.age() age = u.age(),
biography=u.biography,
badges = u.badges()
) )
return dict(users={f'{Snowflake(id):l}': uj})
@bp.get('/user/<b32l:id>')
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})
@rest.route('/post/<b32l:id>') @bp.get('/user/<b32l:id>/feed')
class SinglePost(Resource): async def user_feed_get(id: int):
def get(self, id: int): async with db as session:
p: Post | None = db.session.execute(select(Post).where(Post.id == id)).scalar() 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/@<username>')
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/@<username>/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/<b32l:id>')
async def get_post(id: int):
async with db as session:
p: Post | None = (await session.execute(select(Post).where(Post.id == id))).scalar()
if p is None: if p is None:
return dict(error='Not found'), 404 return dict(error='Not found'), 404
pj = dict( pj = dict(
id = f'{Snowflake(p.id):l}', id = f'{Snowflake(p.id):l}',
title = p.title, title = p.title,
author = p.author.simple_info(), author = p.author.simple_info(),
to = p.topic_or_user().handle(), to = p.topic_or_user().simple_info(typed=True),
created_at = p.created_at.isoformat('T') created_at = p.created_at.isoformat('T')
) )
return dict(posts={f'{Snowflake(id):l}': pj}) 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/@<gname>')
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/@<gname>/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

View file

@ -2,12 +2,8 @@
from typing import Iterable from typing import Iterable
from flask import flash, g
from sqlalchemy import Column, Select, select, or_ from sqlalchemy import Column, Select, select, or_
from .models import Guild, User, db
class SearchQuery: class SearchQuery:
keywords: Iterable[str] keywords: Iterable[str]
@ -27,24 +23,3 @@ class SearchQuery:
return sq 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

View file

@ -11,20 +11,20 @@
<div class="content"> <div class="content">
<h2>Stats</h2> <h2>Stats</h2>
<ul> <ul>
<li>No. of posts: <strong>{{ post_count }}</strong></li> <li># of posts: <strong>{{ post_count }}</strong></li>
<li>No. of active users (posters in the last 30 days): <strong>{{ user_count }}</strong></li> <li># of active users (posters in the last 30 days): <strong>{{ user_count }}</strong></li>
</ul> </ul>
<h2>Software versions</h2> <h2>Software versions</h2>
<ul> <ul>
<li><strong>Python</strong>: {{ python_version }}</strong></li> <li><strong>Python</strong>: {{ python_version }}</strong></li>
<li><strong>SQLAlchemy</strong>: {{ sa_version }}</li> <li><strong>SQLAlchemy</strong>: {{ sa_version }}</li>
<li><strong>Flask</strong>: {{ flask_version }}</li> <li><strong>Quart</strong>: {{ quart_version }}</li>
<li><strong>{{ app_name }}</strong>: {{ app_version }}</li> <li><strong>{{ app_name }}</strong>: {{ app_version }}</li>
</ul> </ul>
<h2>License</h2> <h2>License</h2>
<p>Source code is available at: <a href="https://github.com/yusurko/freak">https://github.com/yusurko/freak</a></p> <p>Source code is available at: <a href="https://nekode.yusur.moe/yusur/freak">https://nekode.yusur.moe/yusur/freak</a></p>
{% if impressum %} {% if impressum %}
<h2>Legal Contacts</h2> <h2>Legal Contacts</h2>

View file

@ -9,7 +9,7 @@
{%- if u.is_administrator %} {%- if u.is_administrator %}
<span>(Admin)</span> <span>(Admin)</span>
{% endif -%} {% endif -%}
{% if u == current_user %} {% if u == current_user.user %}
<span>(You)</span> <span>(You)</span>
{% endif -%} {% endif -%}
</p> </p>

View file

@ -2,7 +2,6 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
{% from "macros/icon.html" import icon with context %} {% from "macros/icon.html" import icon with context %}
{% block title %} {% block title %}
@ -13,7 +12,7 @@
This Service is available "AS IS", with NO WARRANTY, explicit or implied. This Service is available "AS IS", with NO WARRANTY, explicit or implied.
Sakuragasaki46 is NOT legally liable for Your use of the Service. Sakuragasaki46 is NOT legally liable for Your use of the Service.
This service is age-restricted; do not access if underage. This service is age-restricted; do not access if underage.
More info: https://{{ domain_name }}/terms More info: https://{{ server_name }}/terms
--> -->
<meta name="og:site_name" content="{{ app_name }}" /> <meta name="og:site_name" content="{{ app_name }}" />
<meta name="generator" content="{{ app_name }} {{ app_version }}" /> <meta name="generator" content="{{ app_name }} {{ app_version }}" />
@ -26,7 +25,7 @@
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
<script src="{{ jquery_url }}"></script> <script src="{{ jquery_url }}"></script>
</head> </head>
<body {% if current_user.color_theme %} class="{{ theme_classes(current_user.color_theme) }}"{% endif %}> <body {% if current_user and current_user.color_theme %} class="{{ theme_classes(current_user.color_theme) }}"{% endif %}>
<header class="header"> <header class="header">
<h1><a href="/">{{ app_name }}</a></h1> <h1><a href="/">{{ app_name }}</a></h1>
<div class="metanav"> <div class="metanav">
@ -45,9 +44,9 @@
{% endif %} {% endif %}
{% if g.no_user %} {% if g.no_user %}
<!-- no user --> <!-- no user -->
{% elif current_user.is_authenticated %} {% elif current_user %}
<li class="nomobile"> <li class="nomobile">
<a class="round border-accent" href="{{ url_for('create.create', on=current_guild.name) if current_guild and current_guild.allows_posting(current_user) else '/create/' }}" title="Create a post" aria-label="Create a post"> <a class="round border-accent" href="{{ url_for('create.create', on=current_guild.name) if current_guild and current_guild.allows_posting(current_user.user) else '/create/' }}" title="Create a post" aria-label="Create a post">
{{ icon('add') }} {{ icon('add') }}
<span>New post</span> <span>New post</span>
</a> </a>
@ -82,6 +81,7 @@
{% for message in get_flashed_messages() %} {% for message in get_flashed_messages() %}
<div class="flash card">{{ message }}</div> <div class="flash card">{{ message }}</div>
{% endfor %} {% endfor %}
<script>document.write('<div class="flash card">The old HTTP-only frontend is deprecated. Please use the new Svelte frontend.</div>');</script>
{% block body %} {% block body %}
<div class="content-header"> <div class="content-header">
{% block heading %}{% endblock %} {% block heading %}{% endblock %}
@ -105,7 +105,7 @@
<li><a href="https://github.com/sakuragasaki46/freak">GitHub</a></li> <li><a href="https://github.com/sakuragasaki46/freak">GitHub</a></li>
</ul> </ul>
</footer> </footer>
{% if current_user and current_user.is_authenticated %} {% if current_user %}
<footer class="mobile-nav mobileonly"> <footer class="mobile-nav mobileonly">
<ul> <ul>
<li><a href="/" title="Homepage">{{ icon('home') }}</a></li> <li><a href="/" title="Homepage">{{ icon('home') }}</a></li>

View file

@ -75,7 +75,7 @@
<section class="card"> <section class="card">
<h2>Management</h2> <h2>Management</h2>
<!-- TODO: make moderation consensual --> <!-- TODO: make moderation consensual -->
{% if gu.owner == current_user or current_user.is_administrator %} {% if gu.owner == current_user.user or current_user.is_administrator %}
<div> <div>
<label> <label>
Add user as moderator: Add user as moderator:

View file

@ -26,7 +26,7 @@ disabled=""
{% if current_user.is_authenticated %} {% if current_user.is_authenticated %}
{% if current_user.is_disabled %} {% if current_user.is_disabled %}
<div class="centered">Your account is suspended</div> <div class="centered">Your account is suspended</div>
{% elif current_guild and not current_guild.allows_posting(current_user) %} {% elif current_guild and not current_guild.allows_posting(current_user.user) %}
<div class="centered">This community allows only its members to post and comment</div> <div class="centered">This community allows only its members to post and comment</div>
{% elif p.is_locked %} {% elif p.is_locked %}
<div class="centered">Comments are closed</div> <div class="centered">Comments are closed</div>

View file

@ -13,8 +13,8 @@
- <time datetime="{{ p.created_at.isoformat('T') }}">{{ p.created_at.strftime('%B %-d, %Y at %H:%M') }}</time> - <time datetime="{{ p.created_at.isoformat('T') }}">{{ p.created_at.strftime('%B %-d, %Y at %H:%M') }}</time>
</div> </div>
<div class="message-stats"> <div class="message-stats">
{{ feed_upvote(p.id, p.upvotes(), p.upvoted_by(current_user)) }} {{ feed_upvote(p.id, p.upvotes(), p.upvoted_by(current_user.user)) }}
{{ comment_count(p.comments | count) }} {{ comment_count(p.comment_count()) }}
</div> </div>
<div class="message-content shorten"> <div class="message-content shorten">
@ -53,12 +53,12 @@
{% call callout('delete') %}<i>Removed comment</i>{% endcall %} {% call callout('delete') %}<i>Removed comment</i>{% endcall %}
{% else %} {% else %}
<div class="message-meta"> <div class="message-meta">
{% if comment.author %} {% if comment.author_id %}
<a href="{{ comment.author.url() }}">{{ comment.author.handle() }}</a> <a href="{{ comment.author.url() }}">{{ comment.author.handle() }}</a>
{% else %} {% else %}
<i>deleted account</i> <i>deleted account</i>
{% endif %} {% endif %}
{% if comment.author and comment.author == comment.parent_post.author %} {% if comment.author and comment.author_id == comment.parent_post.author_id %}
<span class="faint">(OP)</span> <span class="faint">(OP)</span>
{% endif %} {% endif %}
{# TODO add is_distinguished i.e. official comment #} {# TODO add is_distinguished i.e. official comment #}
@ -70,7 +70,7 @@
{{ comment.text_content | to_markdown }} {{ comment.text_content | to_markdown }}
</div> </div>
<ul class="message-options inline"> <ul class="message-options inline">
{% if comment.author == current_user %} {% if comment.author_id == current_user.id %}
{# TODO add comment edit link #} {# TODO add comment edit link #}
{% else %} {% else %}
<li><a href="{{ comment.report_url() }}">{{ icon('report') }} Report</a></li> <li><a href="{{ comment.report_url() }}">{{ icon('report') }} Report</a></li>

View file

@ -8,7 +8,7 @@
<ul> <ul>
<li><i class="icon icon-info" style="font-size:inherit"></i> {{ gu.description }}</li> <li><i class="icon icon-info" style="font-size:inherit"></i> {{ gu.description }}</li>
<li> <li>
<strong>{{ gu.posts | count }}</strong> posts - <strong>{{ gu.post_count() }}</strong> posts -
<strong>{{ gu.subscriber_count() }}</strong> subscribers <strong>{{ gu.subscriber_count() }}</strong> subscribers
</li> </li>
</ul> </ul>
@ -17,12 +17,12 @@
{% if current_user.moderates(gu) %} {% if current_user.moderates(gu) %}
<a href="{{ gu.url() }}/settings"><button class="card">{{ icon('settings') }} Mod Tools</button></a> <a href="{{ gu.url() }}/settings"><button class="card">{{ icon('settings') }} Mod Tools</button></a>
{% endif %} {% endif %}
{{ subscribe_button(gu, gu.has_subscriber(current_user)) }} {{ subscribe_button(gu, gu.has_subscriber(current_user.user)) }}
{% if not gu.owner %} {% if not gu.owner_id %}
<aside class="card"> <aside class="card">
<p class="centered">{{ gu.handle() }} is currently unmoderated</p> <p class="centered">{{ gu.handle() }} is currently unmoderated</p>
</aside> </aside>
{% elif gu.has_exiled(current_user) %} {% elif gu.has_exiled(current_user.user) %}
<aside class="card"> <aside class="card">
<p class="centered">Moderator list is hidden because you are banned.</p> <p class="centered">Moderator list is hidden because you are banned.</p>
<!-- TODO appeal button --> <!-- TODO appeal button -->
@ -58,11 +58,11 @@
{% endif %} {% endif %}
</ul> </ul>
</aside> </aside>
{% if user == current_user %} {% if user == current_user.user %}
<a href="/settings"><button class="card">{{ icon('settings') }} Settings</button></a> <a href="/settings"><button class="card">{{ icon('settings') }} Settings</button></a>
{% elif current_user.is_authenticated %} {% elif current_user.is_authenticated %}
{{ block_button(user, current_user.has_blocked(user)) }} {{ block_button(user, current_user.has_blocked(user)) }}
{{ subscribe_button(user, user.has_subscriber(current_user)) }} {{ subscribe_button(user, user.has_subscriber(current_user.user)) }}
{% else %} {% else %}
<aside class="card"> <aside class="card">
<p><a href="/login">Log in</a> to subscribe and interact with {{ user.handle() }}</p> <p><a href="/login">Log in</a> to subscribe and interact with {{ user.handle() }}</p>
@ -75,9 +75,9 @@
<h3>Top Communities</h3> <h3>Top Communities</h3>
<ul> <ul>
{% for comm, pcnt, scnt in top_communities %} {% for comm, pcnt, scnt in top_communities %}
<li><strong><a href="{{ comm.url() }}">{{ comm.handle() }}</a></strong> - <strong>{{ pcnt }}</strong> posts - <strong>{{ scnt }}</strong> subscribers</li> <li><strong><a href="/+{{ comm }}">+{{ comm }}</a></strong> - <strong>{{ pcnt }}</strong> posts - <strong>{{ scnt }}</strong> subscribers</li>
{% endfor %} {% endfor %}
{% if current_user and current_user.is_authenticated and current_user.can_create_community() %} {% if current_user and current_user.can_create_community() %}
<li>Cant find your community? <a href="/createcommunity">Create a new one.</a></li> <li>Cant find your community? <a href="/createcommunity">Create a new one.</a></li>
{% endif %} {% endif %}
</ul> </ul>

View file

@ -15,8 +15,8 @@
<div> <div>
<p>You are about to delete <u>permanently</u> <a href="{{ p.url() }}">your post on {{ p.topic_or_user().handle() }}</a>.</p> <p>You are about to delete <u>permanently</u> <a href="{{ p.url() }}">your post on {{ p.topic_or_user().handle() }}</a>.</p>
{% call callout('spoiler', 'error') %}This action <u><b>cannot be undone</b></u>.{% endcall %} {% call callout('spoiler', 'error') %}This action <u><b>cannot be undone</b></u>.{% endcall %}
{% if (p.comments | count) %} {% if (p.comment_count()) %}
{% call callout('spoiler', 'warning') %}Your post has <strong>{{ (p.comments | count) }} comments</strong>. Your post will be deleted <u>along with ALL the comments</u>.{% endcall %} {% call callout('spoiler', 'warning') %}Your post has <strong>{{ (p.comment_count()) }} comments</strong>. Your post will be deleted <u>along with ALL the comments</u>.{% endcall %}
{% endif %} {% endif %}
</div> </div>
<div> <div>

View file

@ -54,11 +54,11 @@
</div> </div>
</div> </div>
<div class="message-stats"> <div class="message-stats">
{{ feed_upvote(p.id, p.upvotes(), p.upvoted_by(current_user)) }} {{ feed_upvote(p.id, p.upvotes(), p.upvoted_by(current_user.user)) }}
{{ comment_count(p.comments | count) }} {{ comment_count(p.comment_count()) }}
</div> </div>
<ul class="message-options inline"> <ul class="message-options inline">
{% if p.author == current_user %} {% if p.author_id == current_user.id %}
<li><a href="/edit/post/{{ p.id|to_b32l }}"><i class="icon icon-edit"></i> Edit</a></li> <li><a href="/edit/post/{{ p.id|to_b32l }}"><i class="icon icon-edit"></i> Edit</a></li>
{% else %} {% else %}
<li><a href="{{ p.report_url() }}"><i class="icon icon-report"></i> Report</a></li> <li><a href="{{ p.report_url() }}"><i class="icon icon-report"></i> Report</a></li>

View file

@ -5,7 +5,10 @@
{% block content %} {% block content %}
<div class="content"> <div class="content">
{# If you host your own instance, rememmber to change Terms to fit your own purposes! #}
{% filter to_markdown %} {% filter to_markdown %}
# Terms of Service # Terms of Service
This is a non-authoritative copy of the actual Terms, always updated at <https://yusur.moe/policies/terms.html>. This is a non-authoritative copy of the actual Terms, always updated at <https://yusur.moe/policies/terms.html>.
@ -20,7 +23,7 @@ The following documents are incorporated into these Terms by reference
## Scope and Definition ## Scope and Definition
These terms of service ("Terms") are between **New Digital Spirit**, i.e. its CEO **Sakuragasaki46**, and You, These terms of service ("Terms") are between **New Digital Spirit** and You,
regarding Your use of all sites and services belonging to New Digital Spirit ("New Digital Spirit Network" / "the Services"), regarding Your use of all sites and services belonging to New Digital Spirit ("New Digital Spirit Network" / "the Services"),
listed in detail in [Privacy Policy](/policies/privacy.html). listed in detail in [Privacy Policy](/policies/privacy.html).

View file

@ -16,7 +16,7 @@
{% endblock %} {% endblock %}
{% block nav %} {% block nav %}
{% if user.is_active and not user.has_blocked(current_user) %} {% if user.is_active and not user.has_blocked(current_user.user) %}
{{ nav_user(user) }} {{ nav_user(user) }}
{% endif %} {% endif %}
{% endblock %} {% endblock %}

View file

@ -5,8 +5,10 @@ import math
import os import os
import time import time
import re import re
from flask import request from quart import request
from suou import deprecated, twocolon_list as _twocolon_list
@deprecated('replaced by suou.age_and_days()')
def age_and_days(date: datetime.datetime, now: datetime.datetime | None = None) -> tuple[int, int]: def age_and_days(date: datetime.datetime, now: datetime.datetime | None = None) -> tuple[int, int]:
if now is None: if now is None:
now = datetime.date.today() now = datetime.date.today()
@ -19,6 +21,7 @@ def get_remote_addr():
return request.headers.getlist('X-Forwarded-For')[0] return request.headers.getlist('X-Forwarded-For')[0]
return request.remote_addr return request.remote_addr
@deprecated('replaced by suou.timed_cache()')
def timed_cache(ttl: int, maxsize: int = 128, typed: bool = False): def timed_cache(ttl: int, maxsize: int = 128, typed: bool = False):
def decorator(func): def decorator(func):
start_time = None start_time = None
@ -39,7 +42,13 @@ def timed_cache(ttl: int, maxsize: int = 128, typed: bool = False):
def is_b32l(username: str) -> bool: def is_b32l(username: str) -> bool:
return re.fullmatch(r'[a-z2-7]+', username) return re.fullmatch(r'[a-z2-7]+', username)
def twocolon_list(s: str | None) -> list[str]: twocolon_list = deprecated('import from suou instead')(_twocolon_list)
if not s:
return [] async def get_request_form() -> dict:
return [x.strip() for x in s.split('::')] """
Get the request form as HTTP x-www-form-urlencoded dict
NEW 0.5.0
"""
return dict(await request.form)

View file

@ -1,27 +1,32 @@
import sys import sys
from flask import Blueprint, render_template, __version__ as flask_version from quart import Blueprint, render_template
import importlib.metadata
try:
from quart import __version__ as quart_version
except Exception:
quart_version = importlib.metadata.version('quart')
from sqlalchemy import __version__ as sa_version from sqlalchemy import __version__ as sa_version
bp = Blueprint('about', __name__) bp = Blueprint('about', __name__)
@bp.route('/about/') @bp.route('/about/')
def about(): async def about():
return render_template('about.html', return await render_template('about.html',
flask_version=flask_version, quart_version=quart_version,
sa_version=sa_version, sa_version=sa_version,
python_version=sys.version.split()[0] python_version=sys.version.split()[0]
) )
@bp.route('/terms/') @bp.route('/terms/')
def terms(): async def terms():
return render_template('terms.html') return await render_template('terms.html')
@bp.route('/privacy/') @bp.route('/privacy/')
def privacy(): async def privacy():
return render_template('privacy.html') return await render_template('privacy.html')
@bp.route('/rules/') @bp.route('/rules/')
def rules(): async def rules():
return render_template('rules.html') return await render_template('rules.html')

View file

@ -1,63 +1,90 @@
from __future__ import annotations from __future__ import annotations
import os, sys import enum
import logging
import sys
import re import re
import datetime import datetime
from typing import Mapping from typing import Mapping
from flask import Blueprint, abort, render_template, request, redirect, flash from quart import Blueprint, render_template, request, redirect, flash
from flask_login import login_required, login_user, logout_user, current_user from quart_auth import AuthUser, login_required, login_user, logout_user, current_user
from suou.functools import deprecated
from werkzeug.exceptions import Forbidden from werkzeug.exceptions import Forbidden
from .. import UserLoader
from ..models import REPORT_REASONS, db, User from ..models import REPORT_REASONS, db, User
from ..utils import age_and_days from ..utils import age_and_days, get_request_form
from sqlalchemy import select, insert from sqlalchemy import select, insert
from werkzeug.security import generate_password_hash from werkzeug.security import generate_password_hash
current_user: User current_user: UserLoader
logger = logging.getLogger(__name__)
bp = Blueprint('accounts', __name__) bp = Blueprint('accounts', __name__)
@bp.route('/login', methods=['GET', 'POST']) from ..accounts import LoginStatus, check_login
def login():
if request.method == 'POST' and request.form['username']:
username = request.form['username']
password = request.form['password']
if '@' in username:
user = db.session.execute(select(User).where(User.email == username)).scalar()
else:
user = db.session.execute(select(User).where(User.username == username)).scalar()
if user and '$' not in user.passhash:
flash('You need to reset your password following the procedure.') @bp.get('/login')
return render_template('login.html') async def login():
elif not user or not user.check_password(password): return await render_template('login.html')
flash('Invalid username or password')
return render_template('login.html') @bp.post('/login')
elif not user.is_active: async def post_login():
flash('Your account is suspended') form = await get_request_form()
else: # TODO schema validator
remember_for = int(request.form.get('remember', 0)) username: str = form['username']
if remember_for > 0: password: str = form['password']
login_user(user, remember=True, duration=datetime.timedelta(days=remember_for)) if '@' in username:
else: user_q = select(User).where(User.email == username)
login_user(user) else:
return redirect(request.args.get('next', '/')) user_q = select(User).where(User.username == username)
return render_template('login.html')
async with db as session:
user = (await session.execute(user_q)).scalar()
match check_login(user, password):
case LoginStatus.SUCCESS:
remember_for = int(form.get('remember', 0))
if remember_for > 0:
login_user(UserLoader(user.get_id()), remember=True)
else:
login_user(UserLoader(user.get_id()))
return redirect(request.args.get('next', '/'))
case LoginStatus.ERROR:
await flash('Invalid username or password')
case LoginStatus.SUSPENDED:
await flash('Your account is suspended')
case LoginStatus.PASS_EXPIRED:
await flash('You need to reset your password following the procedure.')
return await render_template('login.html')
@bp.route('/logout') @bp.route('/logout')
def logout(): async def logout():
logout_user() logout_user()
flash('Logged out. Come back soon~') await flash('Logged out. Come back soon~')
return redirect(request.args.get('next','/')) return redirect(request.args.get('next','/'))
## XXX temp ## XXX temp
@deprecated('no good use')
def _currently_logged_in() -> bool: def _currently_logged_in() -> bool:
return current_user and current_user.is_authenticated return bool(current_user)
def validate_register_form() -> dict:
# XXX temp
@deprecated('please implement IpBan table')
def _check_ip_bans(ip) -> bool:
if ip in ('127.0.0.1', '::1', '::'):
return True
return False
async def validate_register_form() -> dict:
form = await get_request_form()
f = dict() f = dict()
try: try:
f['gdpr_birthday'] = datetime.date.fromisoformat(request.form['birthday']) f['gdpr_birthday'] = datetime.date.fromisoformat(form['birthday'])
if age_and_days(f['gdpr_birthday']) == (0, 0): if age_and_days(f['gdpr_birthday']) == (0, 0):
# block bot attempt to register # block bot attempt to register
@ -68,74 +95,88 @@ def validate_register_form() -> dict:
except ValueError: except ValueError:
raise ValueError('Invalid date format') raise ValueError('Invalid date format')
f['username'] = request.form['username'].lower() f['username'] = form['username'].lower()
if not re.fullmatch('[a-z0-9_-]+', f['username']): if not re.fullmatch('[a-z0-9_-]+', f['username']):
raise ValueError('Username can contain only letters, digits, underscores and dashes.') raise ValueError('Username can contain only letters, digits, underscores and dashes.')
f['display_name'] = request.form.get('full_name') f['display_name'] = form.get('full_name')
if request.form['password'] != request.form['confirm_password']: if form['password'] != form['confirm_password']:
raise ValueError('Passwords do not match.') raise ValueError('Passwords do not match.')
f['passhash'] = generate_password_hash(request.form['password']) f['passhash'] = generate_password_hash(form['password'])
f['email'] = request.form['email'] or None, f['email'] = form['email'] or None
is_ip_banned: bool = await _check_ip_bans()
if is_ip_banned:
raise ValueError('Your IP address is banned.')
if _currently_logged_in() and not request.form.get('confirm_another'): if _currently_logged_in() and not form.get('confirm_another'):
raise ValueError('You are already logged in. Please confirm you want to create another account by checking the option.') raise ValueError('You are already logged in. Please confirm you want to create another account by checking the option.')
if not request.form.get('legal'): if not form.get('legal'):
raise ValueError('You must accept Terms in order to create an account.') raise ValueError('You must accept Terms in order to create an account.')
return f return f
@bp.route('/register', methods=['GET', 'POST']) class RegisterStatus(enum.Enum):
def register(): SUCCESS = 0
if request.method == 'POST' and request.form['username']: ERROR = 1
try: USERNAME_TAKEN = 2
user_data = validate_register_form() IP_BANNED = 3
except ValueError as e:
if e.args:
flash(e.args[0])
return render_template('register.html')
try: @bp.post('/register')
db.session.execute(insert(User).values(**user_data)) async def register_post():
try:
user_data = await validate_register_form()
except ValueError as e:
if e.args:
await flash(e.args[0])
return await render_template('register.html')
db.session.commit() try:
async with db as session:
flash('Account created successfully. You can now log in.') await session.execute(insert(User).values(**user_data))
return redirect(request.args.get('next', '/'))
except Exception as e: await flash('Account created successfully. You can now log in.')
sys.excepthook(*sys.exc_info()) return redirect(request.args.get('next', '/'))
flash('Unable to create account (possibly your username is already taken)') except Exception as e:
return render_template('register.html') sys.excepthook(*sys.exc_info())
await flash('Unable to create account (possibly your username is already taken)')
return await render_template('register.html')
return render_template('register.html') @bp.get('/register')
async def register_get():
return await render_template('register.html')
COLOR_SCHEMES = {'dark': 2, 'light': 1, 'system': 0, 'unset': 0} COLOR_SCHEMES = {'dark': 2, 'light': 1, 'system': 0, 'unset': 0}
@bp.route('/settings', methods=['GET', 'POST']) @bp.route('/settings', methods=['GET', 'POST'])
@login_required @login_required
def settings(): async def settings():
if request.method == 'POST': if request.method == 'POST':
changes = False form = await get_request_form()
user = current_user async with db as session:
color_scheme = COLOR_SCHEMES[request.form.get('color_scheme')] if 'color_scheme' in request.form else None changes = False
color_theme = int(request.form.get('color_theme')) if 'color_theme' in request.form else None user = current_user.user
biography = request.form.get('biography') color_scheme = COLOR_SCHEMES[form.get('color_scheme')] if 'color_scheme' in form else None
display_name = request.form.get('display_name') color_theme: int = int(form.get('color_theme')) if 'color_theme' in form else None
biography: str = form.get('biography')
display_name: str = form.get('display_name')
if display_name and display_name != user.display_name:
changes, user.display_name = True, display_name.strip()
if biography and biography != user.biography:
changes, user.biography = True, biography.strip()
if color_scheme is not None and color_theme is not None:
comp_color_theme = 256 * color_scheme + color_theme
if comp_color_theme != user.color_theme:
changes, user.color_theme = True, comp_color_theme
if changes:
session.add(user)
session.commit()
await flash('Changes saved!')
if display_name and display_name != user.display_name: return await render_template('usersettings.html')
changes, user.display_name = True, display_name.strip()
if biography and biography != user.biography:
changes, user.biography = True, biography.strip()
if color_scheme is not None and color_theme is not None:
comp_color_theme = 256 * color_scheme + color_theme
if comp_color_theme != user.color_theme:
changes, user.color_theme = True, comp_color_theme
if changes:
db.session.add(user)
db.session.commit()
flash('Changes saved!')
return render_template('usersettings.html')

View file

@ -4,27 +4,30 @@ import datetime
from functools import wraps from functools import wraps
from typing import Callable from typing import Callable
import warnings import warnings
from flask import Blueprint, abort, redirect, render_template, request, url_for from quart import Blueprint, abort, redirect, render_template, request, url_for
from flask_login import current_user from quart_auth import current_user
from markupsafe import Markup from markupsafe import Markup
from sqlalchemy import insert, select, update from sqlalchemy import insert, select, update
from suou import additem, not_implemented from suou import additem, not_implemented
from freak import UserLoader
from freak.utils import get_request_form
from ..models import REPORT_REASON_STRINGS, REPORT_REASONS, REPORT_TARGET_COMMENT, REPORT_TARGET_POST, REPORT_UPDATE_COMPLETE, REPORT_UPDATE_ON_HOLD, REPORT_UPDATE_REJECTED, Comment, Post, PostReport, User, UserStrike, db from ..models import REPORT_REASON_STRINGS, REPORT_REASONS, REPORT_TARGET_COMMENT, REPORT_TARGET_POST, REPORT_UPDATE_COMPLETE, REPORT_UPDATE_ON_HOLD, REPORT_UPDATE_REJECTED, Comment, Post, PostReport, User, UserStrike, db
bp = Blueprint('admin', __name__) bp = Blueprint('admin', __name__)
current_user: User current_user: UserLoader
## TODO make admin interface ## TODO make admin interface
def admin_required(func: Callable): def admin_required(func: Callable):
@wraps(func) @wraps(func)
def wrapper(**ka): def wrapper(*a, **ka):
user: User = current_user user: User = current_user.user
if not user.is_authenticated or not user.is_administrator: if not user or not user.is_administrator:
abort(403) abort(403)
return func(**ka) return func(*a, **ka)
return wrapper return wrapper
@ -61,16 +64,17 @@ def colorized_account_status_string(u: User):
base += ' <span class="faint">{1}</span>' base += ' <span class="faint">{1}</span>'
return Markup(base).format(t1, t2 + t3) return Markup(base).format(t1, t2 + t3)
def remove_content(target, reason_code: int): async def remove_content(target, reason_code: int):
if isinstance(target, Post): async with db as session:
target.removed_at = datetime.datetime.now() if isinstance(target, Post):
target.removed_by_id = current_user.id target.removed_at = datetime.datetime.now()
target.removed_reason = reason_code target.removed_by_id = current_user.id
elif isinstance(target, Comment): target.removed_reason = reason_code
target.removed_at = datetime.datetime.now() elif isinstance(target, Comment):
target.removed_by_id = current_user.id target.removed_at = datetime.datetime.now()
target.removed_reason = reason_code target.removed_by_id = current_user.id
db.session.add(target) target.removed_reason = reason_code
session.add(target)
def get_author(target) -> User | None: def get_author(target) -> User | None:
if isinstance(target, (Post, Comment)): if isinstance(target, (Post, Comment)):
@ -89,54 +93,54 @@ def get_content(target) -> str | None:
REPORT_ACTIONS = {} REPORT_ACTIONS = {}
@additem(REPORT_ACTIONS, '1') @additem(REPORT_ACTIONS, '1')
def accept_report(target, source: PostReport): async def accept_report(target, source: PostReport):
if source.is_critical(): async with db as session:
warnings.warn('attempted remove on a critical report case, striking instead', UserWarning) if source.is_critical():
return strike_report(target, source) warnings.warn('attempted remove on a critical report case, striking instead', UserWarning)
return await strike_report(target, source)
remove_content(target, source.reason_code) await remove_content(target, source.reason_code)
source.update_status = REPORT_UPDATE_COMPLETE source.update_status = REPORT_UPDATE_COMPLETE
db.session.add(source) session.add(source)
db.session.commit()
@additem(REPORT_ACTIONS, '2') @additem(REPORT_ACTIONS, '2')
def strike_report(target, source: PostReport): async def strike_report(target, source: PostReport):
remove_content(target, source.reason_code) async with db as session:
await remove_content(target, source.reason_code)
author = get_author(target) author = get_author(target)
if author: if author:
db.session.execute(insert(UserStrike).values( session.execute(insert(UserStrike).values(
user_id = author.id, user_id = author.id,
target_type = TARGET_TYPES[type(target)], target_type = TARGET_TYPES[type(target)],
target_id = target.id, target_id = target.id,
target_content = get_content(target), target_content = get_content(target),
reason_code = source.reason_code, reason_code = source.reason_code,
issued_by_id = current_user.id issued_by_id = current_user.id
)) ))
if source.is_critical(): if source.is_critical():
author.banned_at = datetime.datetime.now() author.banned_at = datetime.datetime.now()
author.banned_reason = source.reason_code author.banned_reason = source.reason_code
source.update_status = REPORT_UPDATE_COMPLETE source.update_status = REPORT_UPDATE_COMPLETE
db.session.add(source) session.add(source)
db.session.commit()
@additem(REPORT_ACTIONS, '0') @additem(REPORT_ACTIONS, '0')
def reject_report(target, source: PostReport): async def reject_report(target, source: PostReport):
source.update_status = REPORT_UPDATE_REJECTED async with db as session:
db.session.add(source) source.update_status = REPORT_UPDATE_REJECTED
db.session.commit() session.add(source)
@additem(REPORT_ACTIONS, '3') @additem(REPORT_ACTIONS, '3')
def withhold_report(target, source: PostReport): async def withhold_report(target, source: PostReport):
source.update_status = REPORT_UPDATE_ON_HOLD async with db as session:
db.session.add(source) source.update_status = REPORT_UPDATE_ON_HOLD
db.session.commit() session.add(source)
@additem(REPORT_ACTIONS, '4') @additem(REPORT_ACTIONS, '4')
@ -148,71 +152,72 @@ def escalate_report(target, source: PostReport):
@bp.route('/admin/') @bp.route('/admin/')
@admin_required @admin_required
def homepage(): async def homepage():
return render_template('admin/admin_home.html') return await render_template('admin/admin_home.html')
@bp.route('/admin/reports/') @bp.route('/admin/reports/')
@admin_required @admin_required
def reports(): async def reports():
report_list = db.paginate(select(PostReport).order_by(PostReport.id.desc())) report_list = db.paginate(select(PostReport).order_by(PostReport.id.desc()))
return render_template('admin/admin_reports.html', return await render_template('admin/admin_reports.html',
report_list=report_list, report_reasons=REPORT_REASON_STRINGS) report_list=report_list, report_reasons=REPORT_REASON_STRINGS)
@bp.route('/admin/reports/<b32l:id>', methods=['GET', 'POST']) @bp.route('/admin/reports/<b32l:id>', methods=['GET', 'POST'])
@admin_required @admin_required
def report_detail(id: int): async def report_detail(id: int):
report = db.session.execute(select(PostReport).where(PostReport.id == id)).scalar() async with db as session:
if report is None: report = (await session.execute(select(PostReport).where(PostReport.id == id))).scalar()
abort(404) if report is None:
if request.method == 'POST': abort(404)
action = REPORT_ACTIONS[request.form['do']] if request.method == 'POST':
action(report.target(), report) form = await get_request_form()
return redirect(url_for('admin.reports')) action = REPORT_ACTIONS[form['do']]
return render_template('admin/admin_report_detail.html', report=report, await action(report.target(), report)
return redirect(url_for('admin.reports'))
return await render_template('admin/admin_report_detail.html', report=report,
report_reasons=REPORT_REASON_STRINGS) report_reasons=REPORT_REASON_STRINGS)
@bp.route('/admin/strikes/') @bp.route('/admin/strikes/')
@admin_required @admin_required
def strikes(): async def strikes():
strike_list = db.paginate(select(UserStrike).order_by(UserStrike.id.desc())) strike_list = await db.paginate(select(UserStrike).order_by(UserStrike.id.desc()))
return render_template('admin/admin_strikes.html', return await render_template('admin/admin_strikes.html',
strike_list=strike_list, report_reasons=REPORT_REASON_STRINGS) strike_list=strike_list, report_reasons=REPORT_REASON_STRINGS)
@bp.route('/admin/users/') @bp.route('/admin/users/')
@admin_required @admin_required
def users(): async def users():
user_list = db.paginate(select(User).order_by(User.joined_at.desc())) user_list = db.paginate(select(User).order_by(User.joined_at.desc()))
return render_template('admin/admin_users.html', return await render_template('admin/admin_users.html',
user_list=user_list, account_status_string=colorized_account_status_string) user_list=user_list, account_status_string=colorized_account_status_string)
@bp.route('/admin/users/<b32l:id>', methods=['GET', 'POST']) @bp.route('/admin/users/<b32l:id>', methods=['GET', 'POST'])
@admin_required @admin_required
def user_detail(id: int): async def user_detail(id: int):
u = db.session.execute(select(User).where(User.id == id)).scalar() async with db as session:
if u is None: u = session.execute(select(User).where(User.id == id)).scalar()
abort(404) if u is None:
if request.method == 'POST': abort(404)
action = request.form['do'] if request.method == 'POST':
if action == 'suspend': form = await get_request_form()
u.banned_at = datetime.datetime.now() action = form['do']
u.banned_by_id = current_user.id if action == 'suspend':
u.banned_reason = REPORT_REASONS.get(request.form.get('reason'), 0) u.banned_at = datetime.datetime.now()
db.session.commit() u.banned_by_id = current_user.id
elif action == 'unsuspend': u.banned_reason = REPORT_REASONS.get(form.get('reason'), 0)
u.banned_at = None elif action == 'unsuspend':
u.banned_by_id = None u.banned_at = None
u.banned_until = None u.banned_by_id = None
u.banned_reason = None u.banned_until = None
db.session.commit() u.banned_reason = None
elif action == 'to_3d': elif action == 'to_3d':
u.banned_at = datetime.datetime.now() u.banned_at = datetime.datetime.now()
u.banned_until = datetime.datetime.now() + datetime.timedelta(days=3) u.banned_until = datetime.datetime.now() + datetime.timedelta(days=3)
u.banned_by_id = current_user.id u.banned_by_id = current_user.id
u.banned_reason = REPORT_REASONS.get(request.form.get('reason'), 0) u.banned_reason = REPORT_REASONS.get(form.get('reason'), 0)
db.session.commit() else:
else: abort(400)
abort(400) strikes = (await session.execute(select(UserStrike).where(UserStrike.user_id == id).order_by(UserStrike.id.desc()))).scalars()
strikes = db.session.execute(select(UserStrike).where(UserStrike.user_id == id).order_by(UserStrike.id.desc())).scalars()
return render_template('admin/admin_user_detail.html', u=u, return render_template('admin/admin_user_detail.html', u=u,
report_reasons=REPORT_REASON_STRINGS, account_status_string=colorized_account_status_string, strikes=strikes) report_reasons=REPORT_REASON_STRINGS, account_status_string=colorized_account_status_string, strikes=strikes)

View file

@ -2,20 +2,23 @@
import sys import sys
import datetime import datetime
from flask import Blueprint, abort, redirect, flash, render_template, request, url_for from quart import Blueprint, abort, redirect, flash, render_template, request, url_for
from flask_login import current_user, login_required from quart_auth import current_user, login_required
from sqlalchemy import insert, select from sqlalchemy import insert, select
from freak import UserLoader
from freak.utils import get_request_form
from ..models import User, db, Guild, Post from ..models import User, db, Guild, Post
current_user: User current_user: UserLoader
bp = Blueprint('create', __name__) bp = Blueprint('create', __name__)
def create_savepoint( async def create_savepoint(
target = '', title = '', content = '', target = '', title = '', content = '',
privacy = 0 privacy = 0
): ):
return render_template('create.html', return await render_template('create.html',
sv_target = target, sv_target = target,
sv_title = title, sv_title = title,
sv_content = content, sv_content = content,
@ -24,74 +27,78 @@ def create_savepoint(
@bp.route('/create/', methods=['GET', 'POST']) @bp.route('/create/', methods=['GET', 'POST'])
@login_required @login_required
def create(): async def create():
user: User = current_user user: User = current_user.user
if request.method == 'POST' and 'title' in request.form: form = await get_request_form()
gname = request.form['to'] if request.method == 'POST' and 'title' in form:
title = request.form['title'] gname = form['to']
text = request.form['text'] title = form['title']
privacy = int(request.form.get('privacy', '0')) text = form['text']
if gname: privacy = int(form.get('privacy', '0'))
guild: Guild | None = db.session.execute(select(Guild).where(Guild.name == gname)).scalar()
if guild is None:
flash(f'Guild +{gname} not found or inaccessible')
return create_savepoint('', title, text, privacy)
if guild.has_exiled(user):
flash(f'You are banned from +{gname}')
return create_savepoint('', title, text, privacy)
if not guild.allows_posting(user):
flash(f'You can\'t post on +{gname}')
return create_savepoint('', title, text, privacy)
else:
guild = None
try:
new_post: Post = db.session.execute(insert(Post).values(
author_id = user.id,
topic_id = guild.id if guild else None,
created_at = datetime.datetime.now(),
privacy = privacy,
title = title,
text_content = text
).returning(Post.id)).fetchone()
db.session.commit() async with db as session:
flash(f'Published on {guild.handle() if guild else user.handle()}') if gname:
return redirect(url_for('detail.post_detail', id=new_post.id)) guild: Guild | None = (await session.execute(select(Guild).where(Guild.name == gname))).scalar()
except Exception as e: if guild is None:
sys.excepthook(*sys.exc_info()) await flash(f'Guild +{gname} not found or inaccessible')
flash('Unable to publish!') return await create_savepoint('', title, text, privacy)
return create_savepoint(target=request.args.get('on','')) if guild.has_exiled(user):
await flash(f'You are banned from +{gname}')
return await create_savepoint('', title, text, privacy)
if not guild.allows_posting(user):
await flash(f'You can\'t post on +{gname}')
return await create_savepoint('', title, text, privacy)
else:
guild = None
try:
new_post_id: int = (await session.execute(insert(Post).values(
author_id = user.id,
topic_id = guild.id if guild else None,
created_at = datetime.datetime.now(),
privacy = privacy,
title = title,
text_content = text
).returning(Post.id))).scalar()
session.commit()
await flash(f'Published on {guild.handle() if guild else user.handle()}')
return redirect(url_for('detail.post_detail', id=new_post_id))
except Exception as e:
sys.excepthook(*sys.exc_info())
await flash('Unable to publish!')
return await create_savepoint(target=request.args.get('on',''))
@bp.route('/createguild/', methods=['GET', 'POST']) @bp.route('/createguild/', methods=['GET', 'POST'])
@login_required @login_required
def createguild(): async def createguild():
if request.method == 'POST': if request.method == 'POST':
user: User = current_user if not current_user.user.can_create_community():
await flash('You are NOT allowed to create new guilds.')
if not user.can_create_community():
flash('You are NOT allowed to create new guilds.')
abort(403) abort(403)
form = await get_request_form()
c_name = request.form['name'] c_name = form['name']
try: try:
new_guild = db.session.execute(insert(Guild).values( async with db as session:
name = c_name, new_guild = (await session.execute(insert(Guild).values(
display_name = request.form.get('display_name', c_name), name = c_name,
description = request.form['description'], display_name = form.get('display_name', c_name),
owner_id = user.id description = form['description'],
).returning(Guild)).scalar() owner_id = current_user.id
).returning(Guild))).scalar()
if new_guild is None: if new_guild is None:
raise RuntimeError('no returning') raise RuntimeError('no returning')
db.session.commit() await session.commit()
return redirect(new_guild.url()) return redirect(new_guild.url())
except Exception: except Exception:
sys.excepthook(*sys.exc_info()) sys.excepthook(*sys.exc_info())
flash('Unable to create guild. It may already exist or you could not have permission to create new communities.') await flash('Unable to create guild. It may already exist or you could not have permission to create new communities.')
return render_template('createguild.html') return await render_template('createguild.html')
@bp.route('/createcommunity/') @bp.route('/createcommunity/')
def createcommunity_redirect(): async def createcommunity_redirect():
return redirect(url_for('create.createguild')), 301 return redirect(url_for('create.createguild')), 301

View file

@ -1,31 +1,35 @@
from __future__ import annotations
from flask import Blueprint, abort, flash, redirect, render_template, request from quart import Blueprint, abort, flash, redirect, render_template, request
from flask_login import current_user, login_required from quart_auth import current_user, login_required
from sqlalchemy import delete, select from sqlalchemy import delete, select
from ..models import Post, db from freak import UserLoader
from ..models import Post, db, User
current_user: UserLoader
bp = Blueprint('delete', __name__) bp = Blueprint('delete', __name__)
@bp.route('/delete/post/<b32l:id>', methods=['GET', 'POST']) @bp.route('/delete/post/<b32l:id>', methods=['GET', 'POST'])
@login_required @login_required
def delete_post(id: int): async def delete_post(id: int):
p = db.session.execute(select(Post).where(Post.id == id, Post.author == current_user)).scalar() async with db as session:
p = (await session.execute(select(Post).where(Post.id == id, Post.author_id == current_user.id))).scalar()
if p is None:
abort(404)
if p.author != current_user:
abort(403)
pt = p.topic_or_user()
if request.method == 'POST': if p is None:
db.session.execute(delete(Post).where(Post.id == id, Post.author == current_user)) abort(404)
db.session.commit() if p.author != current_user.user:
flash('Your post has been deleted') abort(403)
return redirect(pt.url()), 303
pt = p.topic_or_user()
if request.method == 'POST':
session.execute(delete(Post).where(Post.id == id, Post.author_id == current_user.id))
await flash('Your post has been deleted')
return redirect(pt.url()), 303
return render_template('singledelete.html', p=p) return await render_template('singledelete.html', p=p)

View file

@ -1,120 +1,138 @@
from __future__ import annotations
from typing import Iterable from typing import Iterable
from flask import Blueprint, abort, flash, request, redirect, render_template, url_for from quart import Blueprint, abort, flash, request, redirect, render_template, url_for
from flask_login import current_user from quart_auth import current_user
from sqlalchemy import insert, select from sqlalchemy import insert, select
from suou import Snowflake from suou import Snowflake
from ..utils import is_b32l from freak import UserLoader
from ..utils import get_request_form, is_b32l
from ..models import Comment, Guild, db, User, Post from ..models import Comment, Guild, db, User, Post
from ..algorithms import new_comments, user_timeline from ..algorithms import new_comments, user_timeline
current_user: UserLoader
bp = Blueprint('detail', __name__) bp = Blueprint('detail', __name__)
@bp.route('/@<username>') @bp.route('/@<username>')
def user_profile(username): async def user_profile(username):
user = db.session.execute(select(User).where(User.username == username)).scalar() async with db as session:
user = (await session.execute(select(User).where(User.username == username))).scalar()
if user is None: if user is None:
abort(404) abort(404)
posts = user_timeline(user.id) posts = await db.paginate(user_timeline(user))
print(posts.pages)
return render_template('userfeed.html', l=db.paginate(posts), user=user) return await render_template('userfeed.html', l=posts, user=user)
@bp.route('/u/<username>') @bp.route('/u/<username>')
@bp.route('/user/<username>') @bp.route('/user/<username>')
def user_profile_u(username: str): async def user_profile_u(username: str):
if is_b32l(username): if is_b32l(username):
userid = int(Snowflake.from_b32l(username)) userid = int(Snowflake.from_b32l(username))
user = db.session.execute(select(User).where(User.id == userid)).scalar() async with db as session:
if user is not None: user = (await session.execute(select(User).where(User.id == userid))).scalar()
username = user.username if user is not None:
return redirect('/@' + username), 302 username = user.username
return redirect('/@' + username), 302
@bp.route('/@<username>/')
def user_profile_s(username):
return redirect('/@' + username), 301 return redirect('/@' + username), 301
def single_post_post_hook(p: Post): @bp.route('/@<username>/')
async def user_profile_s(username):
return redirect('/@' + username), 301
async def single_post_post_hook(p: Post):
if p.guild is not None: if p.guild is not None:
gu = p.guild gu = p.guild
if gu.has_exiled(current_user): if gu.has_exiled(current_user.user):
flash(f'You have been banned from {gu.handle()}') await flash(f'You have been banned from {gu.handle()}')
return return
if not gu.allows_posting(current_user): if not gu.allows_posting(current_user.user):
flash(f'You can\'t post in {gu.handle()}') await flash(f'You can\'t post in {gu.handle()}')
return return
if p.is_locked: if p.is_locked:
flash(f'You can\'t comment on locked posts') await flash(f'You can\'t comment on locked posts')
return return
if 'reply_to' in request.form: form = await get_request_form()
reply_to_id = request.form['reply_to'] if 'reply_to' in form:
text = request.form['text'] reply_to_id = form['reply_to']
reply_to_p = db.session.execute(db.select(Post).where(Post.id == int(Snowflake.from_b32l(reply_to_id)))).scalar() if reply_to_id else None text = form['text']
db.session.execute(insert(Comment).values( async with db as session:
author_id = current_user.id, reply_to_p = (await session.execute(select(Post).where(Post.id == int(Snowflake.from_b32l(reply_to_id))))).scalar() if reply_to_id else None
parent_post_id = p.id,
parent_comment_id = reply_to_p, session.execute(insert(Comment).values(
text_content = text author_id = current_user.id,
)) parent_post_id = p.id,
db.session.commit() parent_comment_id = reply_to_p,
flash('Comment published') text_content = text
return redirect(p.url()), 303 ))
session.commit()
await flash('Comment published')
return redirect(p.url()), 303
abort(501) abort(501)
@bp.route('/comments/<b32l:id>') @bp.route('/comments/<b32l:id>')
def post_detail(id: int): async def post_detail(id: int):
post: Post | None = db.session.execute(select(Post).where(Post.id == id)).scalar() async with db as session:
post: Post | None = (await session.execute(select(Post).where(Post.id == id))).scalar()
if post and post.url() != request.full_path: if post and post.url() != request.full_path:
return redirect(post.url()), 302 return redirect(post.url()), 302
else: else:
abort(404) abort(404)
def comments_of(p: Post) -> Iterable[Comment]: async def comments_of(p: Post) -> Iterable[Comment]:
## TODO add sort argument ## TODO add sort argument
return db.paginate(new_comments(p)) pp = await db.paginate(new_comments(p))
print(pp.pages)
return pp
@bp.route('/@<username>/comments/<b32l:id>/', methods=['GET', 'POST']) @bp.route('/@<username>/comments/<b32l:id>/', methods=['GET', 'POST'])
@bp.route('/@<username>/comments/<b32l:id>/<slug:slug>', methods=['GET', 'POST']) @bp.route('/@<username>/comments/<b32l:id>/<slug:slug>', methods=['GET', 'POST'])
def user_post_detail(username: str, id: int, slug: str = ''): async 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() async with db as session:
post: Post | None = (await 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.author and post.author.has_blocked(current_user)) or (post.is_removed and post.author != current_user): if post is None or (post.author and await post.author.has_blocked(current_user.user)) or (post.is_removed and post.author != current_user.user):
abort(404) abort(404)
if post.slug and slug != post.slug: if post.slug and slug != post.slug:
return redirect(post.url()), 302 return redirect(post.url()), 302
if request.method == 'POST': if request.method == 'POST':
single_post_post_hook(post) single_post_post_hook(post)
return render_template('singlepost.html', p=post, comments=comments_of(post)) return await render_template('singlepost.html', p=post, comments=await comments_of(post))
@bp.route('/+<gname>/comments/<b32l:id>/', methods=['GET', 'POST']) @bp.route('/+<gname>/comments/<b32l:id>/', methods=['GET', 'POST'])
@bp.route('/+<gname>/comments/<b32l:id>/<slug:slug>', methods=['GET', 'POST']) @bp.route('/+<gname>/comments/<b32l:id>/<slug:slug>', methods=['GET', 'POST'])
def guild_post_detail(gname, id, slug=''): async 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() async with db as session:
post: Post | None = (await session.execute(select(Post).join(Guild).where(Post.id == id, Guild.name == gname))).scalar()
if post is None or (post.author and post.author.has_blocked(current_user)) or (post.is_removed and post.author != current_user): if post is None or (post.author and await post.author.has_blocked(current_user.user)) or (post.is_removed and post.author != current_user.user):
abort(404) abort(404)
if post.slug and slug != post.slug: if post.slug and slug != post.slug:
return redirect(post.url()), 302 return redirect(post.url()), 302
if request.method == 'POST': if request.method == 'POST':
single_post_post_hook(post) single_post_post_hook(post)
return render_template('singlepost.html', p=post, comments=comments_of(post), current_guild = post.guild) return await render_template('singlepost.html', p=post, comments=await comments_of(post), current_guild = post.guild)

View file

@ -2,36 +2,39 @@
import datetime import datetime
from flask import Blueprint, abort, flash, redirect, render_template, request from quart import Blueprint, abort, flash, redirect, render_template, request
from flask_login import current_user, login_required from quart_auth import current_user, login_required
from sqlalchemy import select from sqlalchemy import select, update
from freak.utils import get_request_form
from ..models import Post, db from ..models import Post, db
bp = Blueprint('edit', __name__) bp = Blueprint('edit', __name__)
@bp.route('/edit/post/<b32l:id>', methods=['GET', 'POST']) @bp.route('/edit/post/<b32l:id>', methods=['GET', 'POST'])
@login_required @login_required
def edit_post(id): async def edit_post(id):
p: Post | None = db.session.execute(select(Post).where(Post.id == id, Post.author == current_user)).scalar() async with db as session:
p: Post | None = (await session.execute(select(Post).where(Post.id == id, Post.author == current_user.user))).scalar()
if p is None: if p is None:
abort(404) abort(404)
if current_user.id != p.author.id: if current_user.id != p.author.id:
abort(403) abort(403)
if request.method == 'POST': if request.method == 'POST':
text = request.form['text'] form = await get_request_form()
privacy = int(request.form.get('privacy', '0')) text = form['text']
privacy = int(form.get('privacy', '0'))
db.session.execute(db.update(Post).where(Post.id == id).values( await session.execute(update(Post).where(Post.id == id).values(
text_content = text, text_content = text,
privacy = privacy, privacy = privacy,
updated_at = datetime.datetime.now() updated_at = datetime.datetime.now()
)) ))
db.session.commit() await session.commit()
flash('Your changes have been saved') await flash('Your changes have been saved')
return redirect(p.url()), 303 return redirect(p.url()), 303
return render_template('edit.html', p=p) return await render_template('edit.html', p=p)

View file

@ -1,65 +1,84 @@
from flask import Blueprint, render_template, redirect, abort, request
from flask_login import current_user from __future__ import annotations
from sqlalchemy import select
from quart import Blueprint, render_template, redirect, abort, request
from quart_auth import current_user
from sqlalchemy import and_, distinct, func, select
from freak import UserLoader
from freak.utils import get_request_form
from ..search import SearchQuery from ..search import SearchQuery
from ..models import Guild, Post, db from ..models import Guild, Member, Post, User, db
from ..algorithms import public_timeline, top_guilds_query, topic_timeline from ..algorithms import public_timeline, topic_timeline
current_user: UserLoader
bp = Blueprint('frontpage', __name__) bp = Blueprint('frontpage', __name__)
@bp.route('/') def top_guilds_query():
def homepage(): q_post_count = func.count(distinct(Post.id)).label('post_count')
top_communities = [(x[0], x[1], x[2]) for x in q_sub_count = func.count(distinct(Member.id)).label('sub_count')
db.session.execute(top_guilds_query().limit(10)).fetchall()] qr = select(Guild.name, 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
if current_user and current_user.is_authenticated: @bp.route('/')
async def homepage():
async with db as session:
top_communities = [(x[0], x[1], x[2]) for x in
(await session.execute(top_guilds_query().limit(10))).fetchall()]
if current_user:
# renders user's own timeline # renders user's own timeline
# TODO this is currently the public timeline. # TODO this is currently the public timeline.
return render_template('feed.html', feed_type='foryou', l=db.paginate(public_timeline()), return await render_template('feed.html', feed_type='foryou', l=await db.paginate(public_timeline()),
top_communities=top_communities) top_communities=top_communities)
else: else:
# Show a landing page to anonymous users. # Show a landing page to anonymous users.
return render_template('landing.html', top_communities=top_communities) return await render_template('landing.html', top_communities=top_communities)
@bp.route('/explore/') @bp.route('/explore/')
def explore(): async def explore():
return render_template('feed.html', feed_type='explore', l=db.paginate(public_timeline())) return render_template('feed.html', feed_type='explore', l=db.paginate(public_timeline()))
@bp.route('/+<name>/') @bp.route('/+<name>/')
def guild_feed(name): async def guild_feed(name):
guild: Guild | None = db.session.execute(select(Guild).where(Guild.name == name)).scalar() async with db as session:
guild: Guild | None = (await session.execute(select(Guild).where(Guild.name == name))).scalar()
if guild is None: if guild is None:
abort(404) abort(404)
posts = db.paginate(topic_timeline(name)) posts = await db.paginate(topic_timeline(name))
return render_template( return await render_template(
'feed.html', feed_type='guild', feed_title=f'{guild.display_name} (+{guild.name})', l=posts, guild=guild, 'feed.html', feed_type='guild', feed_title=f'{guild.display_name} (+{guild.name})', l=posts, guild=guild,
current_guild=guild) current_guild=guild)
@bp.route('/r/<name>/') @bp.route('/r/<name>/')
def guild_feed_r(name): async def guild_feed_r(name):
return redirect('/+' + name + '/'), 302 return redirect('/+' + name + '/'), 302
@bp.route("/search", methods=["GET", "POST"]) @bp.route("/search", methods=["GET", "POST"])
def search(): async def search():
if request.method == "POST": if request.method == "POST":
q = request.form["q"] form = await get_request_form()
q = form["q"]
if q: if q:
results = db.paginate(SearchQuery(q).select(Post, [Post.title]).order_by(Post.created_at.desc())) results = db.paginate(SearchQuery(q).select(Post, [Post.title]).order_by(Post.created_at.desc()))
else: else:
results = None results = None
return render_template( return await render_template(
"search.html", "search.html",
results=results, results=results,
q = q q = q
) )
return render_template("search.html") return await render_template("search.html")

View file

@ -1,85 +1,92 @@
from flask import Blueprint, abort, flash, render_template, request from __future__ import annotations
from flask_login import current_user, login_required from quart import Blueprint, abort, flash, render_template, request
from quart_auth import current_user, login_required
from sqlalchemy import select from sqlalchemy import select
import datetime import datetime
from ..models import Member, db, User, Guild from .. import UserLoader
from ..utils import get_request_form
current_user: User from ..models import db, User, Guild
current_user: UserLoader
bp = Blueprint('moderation', __name__) bp = Blueprint('moderation', __name__)
@bp.route('/+<name>/settings', methods=['GET', 'POST']) @bp.route('/+<name>/settings', methods=['GET', 'POST'])
@login_required @login_required
def guild_settings(name: str): async def guild_settings(name: str):
gu = db.session.execute(select(Guild).where(Guild.name == name)).scalar() form = await get_request_form()
if not current_user.moderates(gu): async with db as session:
abort(403) gu = (await session.execute(select(Guild).where(Guild.name == name))).scalar()
if request.method == 'POST': if not current_user.moderates(gu):
if current_user.is_administrator and request.form.get('transfer_owner') == current_user.username: abort(403)
gu.owner_id = current_user.id
db.session.add(gu) if request.method == 'POST':
db.session.commit() if current_user.is_administrator and form.get('transfer_owner') == current_user.username:
flash(f'Claimed ownership of {gu.handle()}') gu.owner_id = current_user.id
return render_template('guildsettings.html', gu=gu) await session.add(gu)
await session.commit()
await flash(f'Claimed ownership of {gu.handle()}')
return await render_template('guildsettings.html', gu=gu)
changes = False changes = False
display_name = request.form.get('display_name') display_name: str = form.get('display_name')
description = request.form.get('description') description: str = form.get('description')
exile_name = request.form.get('exile_name') exile_name: str = form.get('exile_name')
exile_reverse = 'exile_reverse' in request.form exile_reverse = 'exile_reverse' in form
restricted = 'restricted' in request.form restricted = 'restricted' in form
moderator_name = request.form.get('moderator_name') moderator_name: str = form.get('moderator_name')
moderator_consent = 'moderator_consent' in request.form moderator_consent = 'moderator_consent' in form
if description and description != gu.description: if description and description != gu.description:
changes, gu.description = True, description.strip() changes, gu.description = True, description.strip()
if display_name and display_name != gu.display_name: if display_name and display_name != gu.display_name:
changes, gu.display_name = True, display_name.strip() changes, gu.display_name = True, display_name.strip()
if exile_name: if exile_name:
exile_user = db.session.execute(select(User).where(User.username == exile_name)).scalar() exile_user = (await session.execute(select(User).where(User.username == exile_name))).scalar()
if exile_user: if exile_user:
if exile_reverse: if exile_reverse:
mem = gu.update_member(exile_user, banned_at = None, banned_by_id = None) mem = await gu.update_member(exile_user, banned_at = None, banned_by_id = None)
if mem.banned_at == None: if mem.banned_at == None:
flash(f'Removed ban on {exile_user.handle()}') await flash(f'Removed ban on {exile_user.handle()}')
changes = True changes = True
else: else:
mem = gu.update_member(exile_user, banned_at = datetime.datetime.now(), banned_by_id = current_user.id) mem = await gu.update_member(exile_user, banned_at = datetime.datetime.now(), banned_by_id = current_user.id)
if mem.banned_at != None: if mem.banned_at != None:
flash(f'{exile_user.handle()} has been exiled') await flash(f'{exile_user.handle()} has been exiled')
changes = True changes = True
else: else:
flash(f'User \'{exile_name}\' not found, can\'t exile') await flash(f'User \'{exile_name}\' not found, can\'t exile')
if restricted and restricted != gu.is_restricted: if restricted and restricted != gu.is_restricted:
changes, gu.is_restricted = True, restricted changes, gu.is_restricted = True, restricted
if moderator_consent and moderator_name: if moderator_consent and moderator_name:
mu = db.session.execute(select(User).where(User.username == moderator_name)).scalar() mu = (await session.execute(select(User).where(User.username == moderator_name))).scalar()
if mu is None: if mu is None:
flash(f'User \'{moderator_name}\' not found') await flash(f'User \'{moderator_name}\' not found')
elif mu.is_disabled: elif mu.is_disabled:
flash('Suspended users can\'t be moderators') await flash('Suspended users can\'t be moderators')
elif mu.has_blocked(current_user): elif mu.has_blocked(current_user.user):
flash(f'User \'{moderator_name}\' not found') await flash(f'User \'{moderator_name}\' not found')
else: else:
mm = gu.update_member(mu) mm = await gu.update_member(mu)
if mm.is_moderator: if mm.is_moderator:
flash(f'{mu.handle()} is already a moderator') await flash(f'{mu.handle()} is already a moderator')
elif mm.is_banned: elif mm.is_banned:
flash('Exiled users can\'t be moderators') await flash('Exiled users can\'t be moderators')
else: else:
mm.is_moderator = True mm.is_moderator = True
db.session.add(mm) await session.add(mm)
changes = True changes = True
if changes: if changes:
db.session.add(gu) session.add(gu)
db.session.commit() session.commit()
flash('Changes saved!') await flash('Changes saved!')
return render_template('guildsettings.html', gu=gu) return render_template('guildsettings.html', gu=gu)

View file

@ -1,56 +1,64 @@
from __future__ import annotations
from flask import Blueprint, render_template, request from quart import Blueprint, render_template, request
from flask_login import current_user, login_required from quart_auth import current_user, login_required
from ..models import REPORT_TARGET_COMMENT, REPORT_TARGET_POST, ReportReason, post_report_reasons, Comment, Post, PostReport, REPORT_REASONS, db from sqlalchemy import insert, select
from freak import UserLoader
from ..models import REPORT_TARGET_COMMENT, REPORT_TARGET_POST, ReportReason, User, post_report_reasons, Comment, Post, PostReport, REPORT_REASONS, db
bp = Blueprint('reports', __name__) bp = Blueprint('reports', __name__)
current_user: UserLoader
def description_text(rlist: list[ReportReason], key: str) -> str: def description_text(rlist: list[ReportReason], key: str) -> str:
results = [x.description for x in rlist if x.code == key] results = [x.description for x in rlist if x.code == key]
return results[0] if results else key return results[0] if results else key
@bp.route('/report/post/<b32l:id>', methods=['GET', 'POST']) @bp.route('/report/post/<b32l:id>', methods=['GET', 'POST'])
@login_required @login_required
def report_post(id: int): async def report_post(id: int):
p: Post | None = db.session.execute(db.select(Post).where(Post.id == id)).scalar() async with db as session:
if p is None: p: Post | None = (await session.execute(select(Post).where(Post.id == id))).scalar()
return render_template('reports/report_404.html', target_type = 1), 404 if p is None:
if p.author_id == current_user.id: return await render_template('reports/report_404.html', target_type = 1), 404
return render_template('reports/report_self.html', back_to_url=p.url()), 403 if p.author_id == current_user.id:
if request.method == 'POST': return await render_template('reports/report_self.html', back_to_url=p.url()), 403
reason = request.args['reason'] if request.method == 'POST':
db.session.execute(db.insert(PostReport).values( reason = request.args['reason']
author_id = current_user.id, await session.execute(insert(PostReport).values(
target_type = REPORT_TARGET_POST, author_id = current_user.id,
target_id = id, target_type = REPORT_TARGET_POST,
reason_code = REPORT_REASONS[reason] target_id = id,
)) reason_code = REPORT_REASONS[reason]
db.session.commit() ))
return render_template('reports/report_done.html', back_to_url=p.url()) session.commit()
return render_template('reports/report_post.html', id = id, return await render_template('reports/report_done.html', back_to_url=p.url())
return await render_template('reports/report_post.html', id = id,
report_reasons = post_report_reasons, description_text=description_text) report_reasons = post_report_reasons, description_text=description_text)
@bp.route('/report/comment/<b32l:id>', methods=['GET', 'POST']) @bp.route('/report/comment/<b32l:id>', methods=['GET', 'POST'])
@login_required @login_required
def report_comment(id: int): async def report_comment(id: int):
c: Comment | None = db.session.execute(db.select(Comment).where(Comment.id == id)).scalar() async with db as session:
if c is None: c: Comment | None = (await session.execute(select(Comment).where(Comment.id == id))).scalar()
return render_template('reports/report_404.html', target_type = 2), 404 if c is None:
if c.author_id == current_user.id: return await render_template('reports/report_404.html', target_type = 2), 404
return render_template('reports/report_self.html', back_to_url=c.parent_post.url()), 403 if c.author_id == current_user.id:
if request.method == 'POST': return await render_template('reports/report_self.html', back_to_url=c.parent_post.url()), 403
reason = request.args['reason'] if request.method == 'POST':
db.session.execute(db.insert(PostReport).values( reason = request.args['reason']
author_id = current_user.id, session.execute(insert(PostReport).values(
target_type = REPORT_TARGET_COMMENT, author_id = current_user.id,
target_id = id, target_type = REPORT_TARGET_COMMENT,
reason_code = REPORT_REASONS[reason] target_id = id,
)) reason_code = REPORT_REASONS[reason]
db.session.commit() ))
return render_template('reports/report_done.html', session.commit()
back_to_url=c.parent_post.url()) return await render_template('reports/report_done.html',
return render_template('reports/report_comment.html', id = id, back_to_url=c.parent_post.url())
return await render_template('reports/report_comment.html', id = id,
report_reasons = post_report_reasons, description_text=description_text) report_reasons = post_report_reasons, description_text=description_text)

View file

@ -6,19 +6,20 @@ authors = [
dynamic = ["version"] dynamic = ["version"]
dependencies = [ dependencies = [
"Python-Dotenv>=1.0.0", "Python-Dotenv>=1.0.0",
"Flask", "Quart",
"Flask-RestX", "Quart-Schema",
"Python-Slugify", "Python-Slugify",
"SQLAlchemy>=2.0.0", "SQLAlchemy>=2.0.0",
"Flask-SQLAlchemy", # XXX it's Quart-wtFORMS not Quart-wtf see: https://github.com/Quart-Addons/quart-wtf/issues/20
"Flask-WTF", "Quart-WTForms>=1.0.3",
"Flask-Login", "Quart-Auth",
"Alembic", "Alembic",
"Markdown>=3.0.0", "Markdown>=3.0",
"PsycoPG2-binary", "PsycoPG>=3.0",
"libsass", "libsass",
"setuptools>=78.1.0", "setuptools>=78.1.0",
"sakuragasaki46-suou>=0.4.0" "Hypercorn",
"sakuragasaki46-suou>=0.5.2"
] ]
requires-python = ">=3.10" requires-python = ">=3.10"
classifiers = [ classifiers = [

View file

@ -3,7 +3,7 @@ Disallow: /login
Disallow: /logout Disallow: /logout
Disallow: /create Disallow: /create
Disallow: /register Disallow: /register
Disallow: /createcommunity Disallow: /createguild
User-Agent: GPTBot User-Agent: GPTBot
Disallow: / Disallow: /