switch to Quart framework
This commit is contained in:
parent
b97355bb89
commit
73b5b7993f
38 changed files with 1259 additions and 938 deletions
|
|
@ -1,5 +1,11 @@
|
|||
# Changelog
|
||||
|
||||
## 0.5.0
|
||||
|
||||
- Switched to Quart frontend
|
||||
- **BREAKING**: `SERVER_NAME` env variable now contains the domain name. `DOMAIN_NAME` has been removed.
|
||||
- libsuou bumped to 0.5.0
|
||||
|
||||
## 0.4.0
|
||||
|
||||
- Added dependency to [SUOU](https://github.com/sakuragasaki46/suou) library
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@
|
|||
* Will to not give up.
|
||||
* Clone this repository.
|
||||
* Fill in `.env` with the necessary information.
|
||||
* `DOMAIN_NAME` (see above)
|
||||
* `SERVER_NAME` (see above)
|
||||
* `APP_NAME`
|
||||
* `DATABASE_URL` (hint: `postgresql://username:password@localhost/dbname`)
|
||||
* `SECRET_KEY` (you can generate one with the command `cat /dev/random | tr -dc A-Za-z0-9_. | head -c 56`)
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ start-app() {
|
|||
cp -rv /opt/live-app/{freak,pyproject.toml,docker-run.sh} ./
|
||||
cp -v /opt/live-app/.env.prod .env
|
||||
pip install -e .
|
||||
flask --app freak run --host=0.0.0.0
|
||||
hypercorn freak:app -b 0.0.0.0:5000
|
||||
}
|
||||
|
||||
[[ "$1" = "" ]] && start-app
|
||||
|
|
|
|||
|
|
@ -1,30 +1,32 @@
|
|||
|
||||
|
||||
import logging
|
||||
import re
|
||||
from sqlite3 import ProgrammingError
|
||||
import sys
|
||||
from typing import Any
|
||||
import warnings
|
||||
from flask import (
|
||||
Flask, g, redirect, render_template,
|
||||
from quart import (
|
||||
Quart, flash, g, jsonify, redirect, render_template,
|
||||
request, send_from_directory, url_for
|
||||
)
|
||||
import os
|
||||
import dotenv
|
||||
from flask_login import LoginManager
|
||||
from flask_wtf.csrf import CSRFProtect
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
from quart_auth import AuthUser, QuartAuth, Action as QA_Action, current_user
|
||||
from quart_wtf import CSRFProtect
|
||||
from sqlalchemy import inspect, select
|
||||
from suou import Snowflake, ssv_list
|
||||
from werkzeug.routing import BaseConverter
|
||||
from sassutils.wsgi import SassMiddleware
|
||||
from werkzeug.middleware.proxy_fix import ProxyFix
|
||||
from suou.sass import SassAsyncMiddleware
|
||||
from suou.quart import negotiate
|
||||
from hypercorn.middleware import ProxyFixMiddleware
|
||||
|
||||
from suou.configparse import ConfigOptions, ConfigValue
|
||||
from suou import twocolon_list, WantsContentType
|
||||
|
||||
from .colors import color_themes, theme_classes
|
||||
from .utils import twocolon_list
|
||||
|
||||
__version__ = '0.5.0-dev30'
|
||||
__version__ = '0.5.0-dev33'
|
||||
|
||||
APP_BASE_DIR = os.path.dirname(os.path.dirname(__file__))
|
||||
|
||||
|
|
@ -35,31 +37,88 @@ class AppConfig(ConfigOptions):
|
|||
secret_key = ConfigValue(required=True)
|
||||
database_url = ConfigValue(required=True)
|
||||
app_name = ConfigValue()
|
||||
domain_name = ConfigValue()
|
||||
server_name = ConfigValue()
|
||||
private_assets = ConfigValue(cast=ssv_list)
|
||||
jquery_url = ConfigValue(default='https://ajax.googleapis.com/ajax/libs/jquery/3.7.1/jquery.min.js')
|
||||
app_is_behind_proxy = ConfigValue(cast=bool, default=False)
|
||||
app_is_behind_proxy = ConfigValue(cast=int, default=0)
|
||||
impressum = ConfigValue(cast=twocolon_list, default='')
|
||||
create_guild_threshold = ConfigValue(cast=int, default=15, prefix='freak_')
|
||||
|
||||
app_config = AppConfig()
|
||||
|
||||
app = Flask(__name__)
|
||||
logging.basicConfig(level=logging.WARNING)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
app = Quart(__name__)
|
||||
app.secret_key = app_config.secret_key
|
||||
app.config['SQLALCHEMY_DATABASE_URI'] = app_config.database_url
|
||||
app.config['JSONIFY_PRETTYPRINT_REGULAR'] = False
|
||||
app.config['QUART_AUTH_DURATION'] = 365 * 24 * 60 * 60
|
||||
app.config['SERVER_NAME'] = app_config.server_name
|
||||
|
||||
from .models import db, User, Post
|
||||
class UserLoader(AuthUser):
|
||||
"""
|
||||
Loads user from the session.
|
||||
|
||||
*WARNING* requires to be awaited before request before usage!
|
||||
|
||||
Actual User object is at .user; other attributes are proxied.
|
||||
"""
|
||||
def __init__(self, auth_id: str | None, action: QA_Action= QA_Action.PASS):
|
||||
self._auth_id = auth_id
|
||||
self._auth_obj = None
|
||||
self._auth_sess = None
|
||||
self.action = action
|
||||
|
||||
@property
|
||||
def auth_id(self) -> str | None:
|
||||
return self._auth_id
|
||||
|
||||
@property
|
||||
async def is_authenticated(self) -> bool:
|
||||
await self._load()
|
||||
return self._auth_id is not None
|
||||
|
||||
async def _load(self):
|
||||
if self._auth_obj is None and self._auth_id is not None:
|
||||
session = self._auth_sess = await db.begin()
|
||||
self._auth_obj = (await session.execute(select(User).where(User.id == int(self._auth_id)))).scalar()
|
||||
if self._auth_obj is None:
|
||||
raise RuntimeError('failed to fetch user')
|
||||
|
||||
def __getattr__(self, key):
|
||||
if self._auth_obj is None:
|
||||
raise RuntimeError('user is not loaded')
|
||||
return getattr(self._auth_obj, key)
|
||||
|
||||
def __bool__(self):
|
||||
return self._auth_obj is not None
|
||||
|
||||
async def _unload(self):
|
||||
# user is not expected to mutate
|
||||
if self._auth_sess:
|
||||
await self._auth_sess.rollback()
|
||||
|
||||
@property
|
||||
def user(self):
|
||||
return self._auth_obj
|
||||
|
||||
id: int
|
||||
|
||||
## DO NOT ADD LOCAL IMPORTS BEFORE THIS LINE
|
||||
|
||||
from .models import Guild, db, User, Post
|
||||
|
||||
# SASS
|
||||
app.wsgi_app = SassMiddleware(app.wsgi_app, dict(
|
||||
app.asgi_app = SassAsyncMiddleware(app.asgi_app, dict(
|
||||
freak=('static/sass', 'static/css', '/static/css', True)
|
||||
))
|
||||
|
||||
# proxy fix
|
||||
if app_config.app_is_behind_proxy:
|
||||
app.wsgi_app = ProxyFix(
|
||||
app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1
|
||||
app.asgi_app = ProxyFixMiddleware(
|
||||
app.asgi_app, trusted_hops=app_config.app_is_behind_proxy, mode='legacy'
|
||||
)
|
||||
|
||||
class SlugConverter(BaseConverter):
|
||||
|
|
@ -75,100 +134,167 @@ class B32lConverter(BaseConverter):
|
|||
app.url_map.converters['slug'] = SlugConverter
|
||||
app.url_map.converters['b32l'] = B32lConverter
|
||||
|
||||
db.init_app(app)
|
||||
db.bind(app_config.database_url)
|
||||
|
||||
csrf = CSRFProtect(app)
|
||||
|
||||
login_manager = LoginManager(app)
|
||||
login_manager.login_view = 'accounts.login'
|
||||
|
||||
|
||||
|
||||
# TODO configure quart_auth
|
||||
login_manager = QuartAuth(app, user_class= UserLoader)
|
||||
|
||||
from . import filters
|
||||
|
||||
|
||||
PRIVATE_ASSETS = os.getenv('PRIVATE_ASSETS', '').split()
|
||||
|
||||
post_count_cache = 0
|
||||
user_count_cache = 0
|
||||
|
||||
@app.context_processor
|
||||
def _inject_variables():
|
||||
async def _inject_variables():
|
||||
global post_count_cache, user_count_cache
|
||||
try:
|
||||
post_count = await Post.count()
|
||||
user_count = await User.active_count()
|
||||
except Exception as e:
|
||||
logger.error(f'cannot compute post_count: {e}')
|
||||
post_count = post_count_cache
|
||||
user_count = user_count_cache
|
||||
else:
|
||||
post_count_cache = post_count
|
||||
user_count_cache = user_count
|
||||
|
||||
return {
|
||||
'app_name': app_config.app_name,
|
||||
'app_version': __version__,
|
||||
'domain_name': app_config.domain_name,
|
||||
'server_name': app_config.server_name,
|
||||
'url_for_css': (lambda name, **kwargs: url_for('static', filename=f'css/{name}.css', **kwargs)),
|
||||
'private_scripts': [x for x in app_config.private_assets if x.endswith('.js')],
|
||||
'private_styles': [x for x in PRIVATE_ASSETS if x.endswith('.css')],
|
||||
'jquery_url': app_config.jquery_url,
|
||||
'post_count': Post.count(),
|
||||
'user_count': User.active_count(),
|
||||
'post_count': post_count,
|
||||
'user_count': user_count,
|
||||
'colors': color_themes,
|
||||
'theme_classes': theme_classes,
|
||||
'impressum': '\n'.join(app_config.impressum).replace('_', ' ')
|
||||
}
|
||||
|
||||
@login_manager.user_loader
|
||||
def _inject_user(userid):
|
||||
@app.before_request
|
||||
async def _load_user():
|
||||
try:
|
||||
u = db.session.execute(select(User).where(User.id == userid)).scalar()
|
||||
if u is None or u.is_disabled:
|
||||
return None
|
||||
return u
|
||||
except SQLAlchemyError as e:
|
||||
warnings.warn(f'cannot retrieve user {userid} from db (exception: {e})', RuntimeWarning)
|
||||
await current_user._load()
|
||||
except RuntimeError as e:
|
||||
logger.error(f'{e}')
|
||||
g.no_user = True
|
||||
return None
|
||||
|
||||
@app.after_request
|
||||
async def _unload_request(resp):
|
||||
try:
|
||||
await current_user._unload()
|
||||
except RuntimeError as e:
|
||||
logger.error(f'{e}')
|
||||
return resp
|
||||
|
||||
|
||||
def redact_url_password(u: str | Any) -> str | Any:
|
||||
if not isinstance(u, str):
|
||||
return u
|
||||
return re.sub(r':[^@:/ ]+@', ':***@', u)
|
||||
|
||||
async def error_handler_for(status: int, message: str, template: str):
|
||||
match negotiate():
|
||||
case WantsContentType.JSON:
|
||||
return jsonify({'error': f'{message}', 'status': status}), status
|
||||
case WantsContentType.HTML:
|
||||
return await render_template(template, message=f'{message}'), status
|
||||
case WantsContentType.PLAIN:
|
||||
return f'{message} (HTTP {status})', status, {'content-type': 'text/plain; charset=UTF-8'}
|
||||
|
||||
@app.errorhandler(ProgrammingError)
|
||||
def error_db(body):
|
||||
async def error_db(body):
|
||||
g.no_user = True
|
||||
warnings.warn(f'No database access! (url is {redact_url_password(app.config['SQLALCHEMY_DATABASE_URI'])!r})', RuntimeWarning)
|
||||
return render_template('500.html'), 500
|
||||
logger.error(f'No database access! (url is {redact_url_password(app.config['SQLALCHEMY_DATABASE_URI'])!r})', RuntimeWarning)
|
||||
return await error_handler_for(500, body, '500.html')
|
||||
|
||||
@app.errorhandler(400)
|
||||
def error_400(body):
|
||||
return render_template('400.html'), 400
|
||||
async def error_400(body):
|
||||
return await error_handler_for(400, body, '400.html')
|
||||
|
||||
@app.errorhandler(401)
|
||||
async def error_401(body):
|
||||
match negotiate():
|
||||
case WantsContentType.HTML:
|
||||
return redirect(url_for('accounts.login', next=request.path))
|
||||
case _:
|
||||
return await error_handler_for(401, 'Please log in.', 'login.html')
|
||||
|
||||
|
||||
@app.errorhandler(403)
|
||||
def error_403(body):
|
||||
return render_template('403.html'), 403
|
||||
async def error_403(body):
|
||||
return await error_handler_for(403, body, '403.html')
|
||||
|
||||
from .search import find_guild_or_user
|
||||
async def find_guild_or_user(name: str) -> str | None:
|
||||
"""
|
||||
Used in 404 error handler.
|
||||
|
||||
Returns an URL to redirect or None for no redirect.
|
||||
"""
|
||||
|
||||
if hasattr(g, 'no_user'):
|
||||
return None
|
||||
|
||||
# do not execute for non-browsers_
|
||||
if 'Mozilla/' not in request.user_agent.string:
|
||||
return None
|
||||
|
||||
async with db as session:
|
||||
gu = (await session.execute(select(Guild).where(Guild.name == name))).scalar()
|
||||
user = (await session.execute(select(User).where(User.username == name))).scalar()
|
||||
|
||||
if gu is not None:
|
||||
await flash(f'There is nothing at /{name}. Luckily, a guild with name {gu.handle()} happens to exist. Next time, remember to add + before!')
|
||||
return gu.url()
|
||||
|
||||
if user is not None:
|
||||
await flash(f'There is nothing at /{name}. Luckily, a user named {user.handle()} happens to exist. Next time, remember to add @ before!')
|
||||
return user.url()
|
||||
|
||||
return None
|
||||
|
||||
@app.errorhandler(404)
|
||||
def error_404(body):
|
||||
async def error_404(body):
|
||||
try:
|
||||
if mo := re.match(r'/([a-z0-9_-]+)/?', request.path):
|
||||
alternative = find_guild_or_user(mo.group(1))
|
||||
alternative = await find_guild_or_user(mo.group(1))
|
||||
if alternative is not None:
|
||||
return redirect(alternative), 302
|
||||
except Exception as e:
|
||||
warnings.warn(f'Exception in find_guild_or_user: {e}')
|
||||
logger.error(f'Exception in find_guild_or_user: {e}')
|
||||
pass
|
||||
return render_template('404.html'), 404
|
||||
return await error_handler_for(404, 'Not found', '404.html')
|
||||
|
||||
@app.errorhandler(405)
|
||||
def error_405(body):
|
||||
return render_template('405.html'), 405
|
||||
async def error_405(body):
|
||||
return await error_handler_for(405, body, '405.html')
|
||||
|
||||
@app.errorhandler(451)
|
||||
def error_451(body):
|
||||
return render_template('451.html'), 451
|
||||
async def error_451(body):
|
||||
return await error_handler_for(451, body, '451.html')
|
||||
|
||||
@app.errorhandler(500)
|
||||
def error_500(body):
|
||||
async def error_500(body):
|
||||
g.no_user = True
|
||||
return render_template('500.html'), 500
|
||||
return await error_handler_for(500, body, '500.html')
|
||||
|
||||
@app.route('/favicon.ico')
|
||||
def favicon_ico():
|
||||
return send_from_directory(APP_BASE_DIR, 'favicon.ico')
|
||||
async def favicon_ico():
|
||||
return await send_from_directory(APP_BASE_DIR, 'favicon.ico')
|
||||
|
||||
@app.route('/robots.txt')
|
||||
def robots_txt():
|
||||
return send_from_directory(APP_BASE_DIR, 'robots.txt')
|
||||
async def robots_txt():
|
||||
return await send_from_directory(APP_BASE_DIR, 'robots.txt')
|
||||
|
||||
|
||||
from .website import blueprints
|
||||
|
|
@ -178,8 +304,8 @@ for bp in blueprints:
|
|||
from .ajax import bp
|
||||
app.register_blueprint(bp)
|
||||
|
||||
from .rest import rest_bp
|
||||
app.register_blueprint(rest_bp)
|
||||
from .rest import bp
|
||||
app.register_blueprint(bp)
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
|
||||
import asyncio
|
||||
from .cli import main
|
||||
|
||||
main()
|
||||
asyncio.run(main())
|
||||
|
||||
|
|
|
|||
118
freak/ajax.py
118
freak/ajax.py
|
|
@ -1,29 +1,35 @@
|
|||
|
||||
'''
|
||||
AJAX hooks for the website.
|
||||
AJAX hooks for the OLD frontend.
|
||||
|
||||
2025 DEPRECATED in favor of /v1/ (REST)
|
||||
DEPRECATED in 0.5 in favor of /v1/ (REST)
|
||||
'''
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from flask import Blueprint, abort, flash, redirect, request
|
||||
from quart import Blueprint, abort, flash, redirect, request
|
||||
from sqlalchemy import delete, insert, select
|
||||
from .models import Guild, Member, UserBlock, db, User, Post, PostUpvote, username_is_legal
|
||||
from flask_login import current_user, login_required
|
||||
|
||||
current_user: User
|
||||
from freak import UserLoader
|
||||
from freak.utils import get_request_form
|
||||
from .models import Guild, Member, UserBlock, db, User, Post, PostUpvote, username_is_legal
|
||||
from quart_auth import current_user, login_required
|
||||
|
||||
current_user: UserLoader
|
||||
|
||||
bp = Blueprint('ajax', __name__)
|
||||
|
||||
@bp.route('/username_availability/<username>')
|
||||
@bp.route('/ajax/username_availability/<username>')
|
||||
def username_availability(username: str):
|
||||
async def username_availability(username: str):
|
||||
is_valid = username_is_legal(username)
|
||||
|
||||
if is_valid:
|
||||
user = db.session.execute(select(User).where(User.username == username)).scalar()
|
||||
async with db as session:
|
||||
user = (await session.execute(select(User).where(User.username == username))).scalar()
|
||||
|
||||
is_available = user is None or user == current_user
|
||||
is_available = user is None or user == current_user.user
|
||||
else:
|
||||
is_available = False
|
||||
|
||||
|
|
@ -34,11 +40,12 @@ def username_availability(username: str):
|
|||
}
|
||||
|
||||
@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)
|
||||
|
||||
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
|
||||
else:
|
||||
|
|
@ -52,101 +59,112 @@ def guild_name_availability(name: str):
|
|||
|
||||
@bp.route('/comments/<b32l:id>/upvote', methods=['POST'])
|
||||
@login_required
|
||||
def post_upvote(id):
|
||||
o = request.form['o']
|
||||
p: Post | None = db.session.execute(select(Post).where(Post.id == id)).scalar()
|
||||
async def post_upvote(id):
|
||||
form = await get_request_form()
|
||||
o = form['o']
|
||||
async with db as session:
|
||||
p: Post | None = (await session.execute(select(Post).where(Post.id == id))).scalar()
|
||||
|
||||
if p is None:
|
||||
return { 'status': 'fail', 'message': 'Post not found' }, 404
|
||||
|
||||
if o == '1':
|
||||
db.session.execute(delete(PostUpvote).where(PostUpvote.c.post_id == p.id, PostUpvote.c.voter_id == current_user.id, PostUpvote.c.is_downvote == True))
|
||||
db.session.execute(insert(PostUpvote).values(post_id = p.id, voter_id = current_user.id, is_downvote = False))
|
||||
elif o == '0':
|
||||
db.session.execute(delete(PostUpvote).where(PostUpvote.c.post_id == p.id, PostUpvote.c.voter_id == current_user.id))
|
||||
elif o == '-1':
|
||||
db.session.execute(delete(PostUpvote).where(PostUpvote.c.post_id == p.id, PostUpvote.c.voter_id == current_user.id, PostUpvote.c.is_downvote == False))
|
||||
db.session.execute(insert(PostUpvote).values(post_id = p.id, voter_id = current_user.id, is_downvote = True))
|
||||
else:
|
||||
cur_score = await p.upvoted_by(current_user.user)
|
||||
|
||||
match (o, cur_score):
|
||||
case ('1', 0) | ('1', -1):
|
||||
await session.execute(delete(PostUpvote).where(PostUpvote.c.post_id == p.id, PostUpvote.c.voter_id == current_user.id, PostUpvote.c.is_downvote == True))
|
||||
await session.execute(insert(PostUpvote).values(post_id = p.id, voter_id = current_user.id, is_downvote = False))
|
||||
case ('0', _):
|
||||
await session.execute(delete(PostUpvote).where(PostUpvote.c.post_id == p.id, PostUpvote.c.voter_id == current_user.id))
|
||||
case ('-1', 1) | ('-1', 0):
|
||||
await session.execute(delete(PostUpvote).where(PostUpvote.c.post_id == p.id, PostUpvote.c.voter_id == current_user.id, PostUpvote.c.is_downvote == False))
|
||||
await session.execute(insert(PostUpvote).values(post_id = p.id, voter_id = current_user.id, is_downvote = True))
|
||||
case ('1', 1) | ('-1', -1):
|
||||
pass
|
||||
case _:
|
||||
await session.rollback()
|
||||
return { 'status': 'fail', 'message': 'Invalid score' }, 400
|
||||
|
||||
db.session.commit()
|
||||
return { 'status': 'ok', 'count': p.upvotes() }
|
||||
await session.commit()
|
||||
return { 'status': 'ok', 'count': await p.upvotes() }
|
||||
|
||||
@bp.route('/@<username>/block', methods=['POST'])
|
||||
@login_required
|
||||
def block_user(username):
|
||||
u = db.session.execute(select(User).where(User.username == username)).scalar()
|
||||
async def block_user(username):
|
||||
form = await get_request_form()
|
||||
|
||||
async with db as session:
|
||||
u = (await session.execute(select(User).where(User.username == username))).scalar()
|
||||
|
||||
if u is None:
|
||||
abort(404)
|
||||
|
||||
is_block = 'reverse' not in request.form
|
||||
is_unblock = request.form.get('reverse') == '1'
|
||||
is_block = 'reverse' not in form
|
||||
is_unblock = form.get('reverse') == '1'
|
||||
|
||||
if is_block:
|
||||
if current_user.has_blocked(u):
|
||||
flash(f'{u.handle()} is already blocked')
|
||||
await flash(f'{u.handle()} is already blocked')
|
||||
else:
|
||||
db.session.execute(insert(UserBlock).values(
|
||||
await session.execute(insert(UserBlock).values(
|
||||
actor_id = current_user.id,
|
||||
target_id = u.id
|
||||
))
|
||||
db.session.commit()
|
||||
flash(f'{u.handle()} is now blocked')
|
||||
await flash(f'{u.handle()} is now blocked')
|
||||
|
||||
if is_unblock:
|
||||
if not current_user.has_blocked(u):
|
||||
flash('You didn\'t block this user')
|
||||
await flash('You didn\'t block this user')
|
||||
else:
|
||||
db.session.execute(delete(UserBlock).where(
|
||||
await 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()}')
|
||||
await flash(f'Removed block on {u.handle()}')
|
||||
|
||||
return redirect(request.args.get('next', u.url())), 303
|
||||
|
||||
@bp.route('/+<name>/subscribe', methods=['POST'])
|
||||
@login_required
|
||||
def subscribe_guild(name):
|
||||
gu = db.session.execute(select(Guild).where(Guild.name == name)).scalar()
|
||||
async def subscribe_guild(name):
|
||||
form = await get_request_form()
|
||||
|
||||
async with db as session:
|
||||
gu = (await session.execute(select(Guild).where(Guild.name == name))).scalar()
|
||||
|
||||
if gu is None:
|
||||
abort(404)
|
||||
|
||||
is_join = 'reverse' not in request.form
|
||||
is_leave = request.form.get('reverse') == '1'
|
||||
is_join = 'reverse' not in form
|
||||
is_leave = form.get('reverse') == '1'
|
||||
|
||||
membership = db.session.execute(select(Member).where(Member.guild == gu, Member.user_id == current_user.id)).scalar()
|
||||
membership = (await session.execute(select(Member).where(Member.guild == gu, Member.user_id == current_user.id))).scalar()
|
||||
|
||||
if is_join:
|
||||
if membership is None:
|
||||
membership = db.session.execute(insert(Member).values(
|
||||
membership = (await session.execute(insert(Member).values(
|
||||
guild_id = gu.id,
|
||||
user_id = current_user.id,
|
||||
is_subscribed = True
|
||||
).returning(Member)).scalar()
|
||||
).returning(Member))).scalar()
|
||||
elif membership.is_subscribed == False:
|
||||
membership.is_subscribed = True
|
||||
db.session.add(membership)
|
||||
await session.add(membership)
|
||||
else:
|
||||
return redirect(gu.url()), 303
|
||||
db.session.commit()
|
||||
flash(f"You are now subscribed to {gu.handle()}")
|
||||
await flash(f"You are now subscribed to {gu.handle()}")
|
||||
|
||||
if is_leave:
|
||||
if membership is None:
|
||||
return redirect(gu.url()), 303
|
||||
elif membership.is_subscribed == True:
|
||||
membership.is_subscribed = False
|
||||
db.session.add(membership)
|
||||
await session.add(membership)
|
||||
else:
|
||||
return redirect(gu.url()), 303
|
||||
|
||||
db.session.commit()
|
||||
flash(f"Unsubscribed from {gu.handle()}.")
|
||||
await session.commit()
|
||||
await flash(f"Unsubscribed from {gu.handle()}.")
|
||||
|
||||
return redirect(gu.url()), 303
|
||||
|
||||
|
|
|
|||
|
|
@ -2,15 +2,16 @@
|
|||
|
||||
from flask_login import current_user
|
||||
from sqlalchemy import and_, distinct, func, select
|
||||
from .models import Comment, Member, db, Post, Guild, User
|
||||
|
||||
current_user: User
|
||||
from .models import Comment, Member, Post, Guild, User
|
||||
|
||||
|
||||
|
||||
def cuser() -> User:
|
||||
return current_user if current_user.is_authenticated else None
|
||||
return current_user.user if current_user else None
|
||||
|
||||
def cuser_id() -> int:
|
||||
return current_user.id if current_user.is_authenticated else None
|
||||
return current_user.id if current_user else None
|
||||
|
||||
def public_timeline():
|
||||
return select(Post).join(User, User.id == Post.author_id).where(
|
||||
|
|
@ -18,24 +19,25 @@ def public_timeline():
|
|||
).order_by(Post.created_at.desc())
|
||||
|
||||
def topic_timeline(gname):
|
||||
return select(Post).join(Guild).join(User, User.id == Post.author_id).where(
|
||||
return select(Post).join(Guild, Guild.id == Post.topic_id).join(User, User.id == Post.author_id).where(
|
||||
Post.privacy == 0, Guild.name == gname, User.not_suspended(), Post.not_removed(), User.has_not_blocked(Post.author_id, cuser_id())
|
||||
).order_by(Post.created_at.desc())
|
||||
|
||||
def user_timeline(user_id):
|
||||
def user_timeline(user: User):
|
||||
return select(Post).join(User, User.id == Post.author_id).where(
|
||||
Post.visible_by(cuser_id()), User.id == user_id, User.not_suspended(), Post.not_removed(), User.has_not_blocked(Post.author_id, cuser_id())
|
||||
Post.visible_by(cuser_id()), Post.author_id == user.id, User.not_suspended(), Post.not_removed(), User.has_not_blocked(Post.author_id, cuser_id())
|
||||
).order_by(Post.created_at.desc())
|
||||
|
||||
def top_guilds_query():
|
||||
q_post_count = func.count(distinct(Post.id)).label('post_count')
|
||||
q_sub_count = func.count(distinct(Member.id)).label('sub_count')
|
||||
qr = select(Guild, q_post_count, q_sub_count)\
|
||||
.join(Post, Post.topic_id == Guild.id, isouter=True)\
|
||||
.join(Member, and_(Member.guild_id == Guild.id, Member.is_subscribed == True), isouter=True)\
|
||||
.group_by(Guild).having(q_post_count > 5).order_by(q_post_count.desc(), q_sub_count.desc())
|
||||
return qr
|
||||
|
||||
def new_comments(p: Post):
|
||||
return select(Comment).where(Comment.parent_post_id == p.id, Comment.parent_comment_id == None,
|
||||
return select(Comment).join(Post, Post.id == Comment.parent_post_id).join(User, User.id == Comment.author_id).where(Comment.parent_post_id == p.id, Comment.parent_comment_id == None,
|
||||
Comment.not_removed(), User.has_not_blocked(Comment.author_id, cuser_id())).order_by(Comment.created_at.desc())
|
||||
|
||||
|
||||
class Algorithms:
|
||||
"""
|
||||
Return SQL queries for algorithms.
|
||||
"""
|
||||
def __init__(self, me: User | None):
|
||||
self.me = me
|
||||
|
||||
|
||||
17
freak/cli.py
17
freak/cli.py
|
|
@ -6,7 +6,7 @@ import subprocess
|
|||
|
||||
from sqlalchemy import create_engine, select
|
||||
from sqlalchemy.orm import Session
|
||||
from . import __version__ as version, app
|
||||
from . import __version__ as version, app_config
|
||||
from .models import User, db
|
||||
|
||||
def make_parser():
|
||||
|
|
@ -16,7 +16,7 @@ def make_parser():
|
|||
parser.add_argument('--flush', '-H', action='store_true', help='recompute karma for all users')
|
||||
return parser
|
||||
|
||||
def main():
|
||||
async def main():
|
||||
args = make_parser().parse_args()
|
||||
|
||||
engine = create_engine(os.getenv('DATABASE_URL'))
|
||||
|
|
@ -26,18 +26,19 @@ def main():
|
|||
print(f'Schema upgrade failed (code: {ret_code})')
|
||||
exit(ret_code)
|
||||
# if the alembic/versions folder is empty
|
||||
db.metadata.create_all(engine)
|
||||
await db.create_all(engine)
|
||||
print('Schema upgraded!')
|
||||
|
||||
if args.flush:
|
||||
cnt = 0
|
||||
with app.app_context():
|
||||
for u in db.session.execute(select(User)).scalars():
|
||||
async with db as session:
|
||||
|
||||
for u in (await session.execute(select(User))).scalars():
|
||||
u.recompute_karma()
|
||||
cnt += 1
|
||||
db.session.add(u)
|
||||
db.session.commit()
|
||||
session.add(u)
|
||||
session.commit()
|
||||
print(f'Recomputed karma of {cnt} users')
|
||||
|
||||
print(f'Visit <https://{os.getenv("DOMAIN_NAME")}>')
|
||||
print(f'Visit <https://{app_config.server_name}>')
|
||||
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ color_themes = [
|
|||
ColorTheme(10, 'Defoko'),
|
||||
ColorTheme(11, 'Kaito'),
|
||||
ColorTheme(12, 'Meiko'),
|
||||
ColorTheme(13, 'Leek'),
|
||||
ColorTheme(13, 'WhatsApp'),
|
||||
ColorTheme(14, 'Teto'),
|
||||
ColorTheme(15, 'Ruby')
|
||||
]
|
||||
|
|
|
|||
77
freak/dei.py
77
freak/dei.py
|
|
@ -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)
|
||||
292
freak/models.py
292
freak/models.py
|
|
@ -8,22 +8,31 @@ from functools import partial
|
|||
from operator import or_
|
||||
import re
|
||||
from threading import Lock
|
||||
from sqlalchemy import Column, Integer, String, ForeignKey, UniqueConstraint, insert, text, \
|
||||
from typing import Any, Callable
|
||||
from quart_auth import AuthUser, current_user
|
||||
from sqlalchemy import Column, ExceptionContext, Integer, String, ForeignKey, UniqueConstraint, and_, insert, text, \
|
||||
CheckConstraint, Date, DateTime, Boolean, func, BigInteger, \
|
||||
SmallInteger, select, update, Table
|
||||
from sqlalchemy.orm import Relationship, relationship
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
from flask_login import AnonymousUserMixin
|
||||
from suou import SiqType, Snowflake, Wanted, deprecated, not_implemented
|
||||
from suou.sqlalchemy_async import SQLAlchemy
|
||||
from suou import SiqType, Snowflake, Wanted, deprecated, makelist, not_implemented
|
||||
from suou.sqlalchemy import create_session, declarative_base, id_column, parent_children, snowflake_column
|
||||
from werkzeug.security import check_password_hash
|
||||
|
||||
from freak import app_config
|
||||
from .utils import age_and_days, get_remote_addr, timed_cache
|
||||
from . import UserLoader, app_config
|
||||
from .utils import get_remote_addr
|
||||
|
||||
from suou import timed_cache, age_and_days
|
||||
|
||||
current_user: UserLoader
|
||||
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
## Constants and enums
|
||||
|
||||
## NOT IN USE: User has .banned_at and .is_disabled_by_user
|
||||
USER_ACTIVE = 0
|
||||
USER_INACTIVE = 1
|
||||
USER_BANNED = 2
|
||||
|
|
@ -71,16 +80,16 @@ ILLEGAL_USERNAMES = tuple((
|
|||
'me everyone here room all any server app dev devel develop nil none '
|
||||
'founder owner admin administrator mod modteam moderator sysop some '
|
||||
## fictitious users and automations
|
||||
'nobody deleted suspended default bot developer undefined null '
|
||||
'ai automod automoderator assistant privacy anonymous removed assistance '
|
||||
'nobody somebody deleted suspended default bot developer undefined null '
|
||||
'ai automod clanker automoderator assistant privacy anonymous removed assistance '
|
||||
## law enforcement corps and slurs because yes
|
||||
'pedo rape rapist nigger retard ncmec police cops 911 childsafety '
|
||||
'report dmca login logout security order66 gestapo ss hitler heilhitler kgb '
|
||||
'pedophile lolicon giphy tenor csam cp pedobear lolita lolice thanos '
|
||||
'loli kkk pnf adl cop tranny google trustandsafety safety ice fbi nsa it '
|
||||
'loli lolicon kkk pnf adl cop tranny google trustandsafety safety ice fbi nsa it '
|
||||
## VVVVIP
|
||||
'potus realdonaldtrump elonmusk teddysphotos mrbeast jkrowling pewdiepie '
|
||||
'elizabethii king queen pontifex hogwarts lumos alohomora isis daesh '
|
||||
'elizabethii elizabeth2 king queen pontifex hogwarts lumos alohomora isis daesh retards '
|
||||
).split())
|
||||
|
||||
def username_is_legal(username: str) -> bool:
|
||||
|
|
@ -94,21 +103,23 @@ def username_is_legal(username: str) -> bool:
|
|||
return False
|
||||
return True
|
||||
|
||||
def want_User(o: User | Any | None, *, prefix: str = '', var_name: str = '') -> User | None:
|
||||
if isinstance(o, User):
|
||||
return o
|
||||
if o is None:
|
||||
return None
|
||||
logger.warning(f'{prefix}: {repr(var_name) + " has " if var_name else ""}invalid type {o.__class__.__name__}, expected User')
|
||||
return None
|
||||
|
||||
## END constants and enums
|
||||
|
||||
Base = declarative_base(app_config.domain_name, app_config.secret_key,
|
||||
Base = declarative_base(app_config.server_name, app_config.secret_key,
|
||||
snowflake_epoch=1577833200)
|
||||
db = SQLAlchemy(model_class=Base)
|
||||
|
||||
CSI = create_session_interactively = partial(create_session, app_config.database_url)
|
||||
|
||||
|
||||
# the BaseModel() class will be removed in 0.5
|
||||
from .iding import new_id
|
||||
@deprecated('id_column() and explicit id column are better. Will be removed in 0.5')
|
||||
class BaseModel(Base):
|
||||
__abstract__ = True
|
||||
id = Column(BigInteger, primary_key=True, default=new_id)
|
||||
|
||||
## Many-to-many relationship keys for some reasons have to go
|
||||
## BEFORE other table definitions.
|
||||
|
|
@ -151,6 +162,7 @@ class User(Base):
|
|||
karma = Column(BigInteger, server_default=text('0'), nullable=False)
|
||||
legacy_id = Column(BigInteger, nullable=True)
|
||||
|
||||
# pronouns must be set via suou.dei.Pronoun.from_short()
|
||||
pronouns = Column(Integer, server_default=text('0'), nullable=False)
|
||||
biography = Column(String(1024), nullable=True)
|
||||
color_theme = Column(SmallInteger, nullable=False, server_default=text('0'))
|
||||
|
|
@ -171,8 +183,8 @@ class User(Base):
|
|||
## SQLAlchemy fail initialization of models — bricking the app.
|
||||
## Posts are queried manually anyway
|
||||
#posts = relationship("Post", primaryjoin=lambda: #back_populates='author', pr)
|
||||
upvoted_posts = relationship("Post", secondary=PostUpvote, back_populates='upvoters')
|
||||
#comments = relationship("Comment", back_populates='author')
|
||||
upvoted_posts = relationship("Post", secondary=PostUpvote, back_populates='upvoters', lazy='selectin')
|
||||
#comments = relationship("Comment", back_populates='author', lazy='selectin')
|
||||
|
||||
@property
|
||||
def is_disabled(self):
|
||||
|
|
@ -189,13 +201,16 @@ class User(Base):
|
|||
return not self.is_disabled
|
||||
|
||||
@property
|
||||
@deprecated('shadowed by UserLoader.is_authenticated(), and always true')
|
||||
def is_authenticated(self):
|
||||
return True
|
||||
|
||||
@property
|
||||
@deprecated('no more in use since switch to Quart')
|
||||
def is_anonymous(self):
|
||||
return False
|
||||
|
||||
@deprecated('this representation uses decimal, URLs use b32l')
|
||||
def get_id(self):
|
||||
return str(self.id)
|
||||
|
||||
|
|
@ -215,17 +230,19 @@ class User(Base):
|
|||
id = Snowflake(self.id).to_b32l(),
|
||||
username = self.username,
|
||||
display_name = self.display_name,
|
||||
age = self.age()
|
||||
## TODO add badges?
|
||||
age = self.age(),
|
||||
badges = self.badges()
|
||||
)
|
||||
|
||||
def reward(self, points=1):
|
||||
@deprecated('updates may be not atomic. DO NOT USE until further notice')
|
||||
async def reward(self, points=1):
|
||||
"""
|
||||
Manipulate a user's karma on the fly
|
||||
"""
|
||||
with Lock():
|
||||
db.session.execute(update(User).where(User.id == self.id).values(karma = self.karma + points))
|
||||
db.session.commit()
|
||||
async with db as session:
|
||||
await session.execute(update(User).where(User.id == self.id).values(karma = self.karma + points))
|
||||
await session.commit()
|
||||
|
||||
def can_create_guild(self):
|
||||
## TODO make guild creation requirements fully configurable
|
||||
|
|
@ -240,10 +257,12 @@ class User(Base):
|
|||
return check_password_hash(self.passhash, password)
|
||||
|
||||
@classmethod
|
||||
@timed_cache(1800)
|
||||
def active_count(cls) -> int:
|
||||
@timed_cache(1800, async_=True)
|
||||
async def active_count(cls) -> int:
|
||||
active_th = datetime.datetime.now() - datetime.timedelta(days=30)
|
||||
return db.session.execute(select(func.count(User.id)).select_from(cls).join(Post, Post.author_id == User.id).where(Post.created_at >= active_th).group_by(User.id)).scalar()
|
||||
async with db as session:
|
||||
count = (await session.execute(select(func.count(User.id)).select_from(cls).join(Post, Post.author_id == User.id).where(Post.created_at >= active_th).group_by(User.id))).scalar()
|
||||
return count
|
||||
|
||||
def __repr__(self):
|
||||
return f'<{self.__class__.__name__} id:{self.id!r} username:{self.username!r}>'
|
||||
|
|
@ -252,10 +271,25 @@ class User(Base):
|
|||
def not_suspended(cls):
|
||||
return or_(User.banned_at == None, User.banned_until <= datetime.datetime.now())
|
||||
|
||||
def has_blocked(self, other: User | None) -> bool:
|
||||
if other is None or not other.is_authenticated:
|
||||
async def has_blocked(self, other: User | None) -> bool:
|
||||
if not want_User(other, var_name='other', prefix='User.has_blocked()'):
|
||||
return False
|
||||
return bool(db.session.execute(select(UserBlock).where(UserBlock.c.actor_id == self.id, UserBlock.c.target_id == other.id)).scalar())
|
||||
async with db as session:
|
||||
block_exists = (await session.execute(select(UserBlock).where(UserBlock.c.actor_id == self.id, UserBlock.c.target_id == other.id))).scalar()
|
||||
return bool(block_exists)
|
||||
|
||||
async def is_blocked_by(self, other: User | None) -> bool:
|
||||
if not want_User(other, var_name='other', prefix='User.is_blocked_by()'):
|
||||
return False
|
||||
async with db as session:
|
||||
block_exists = (await session.execute(select(UserBlock).where(UserBlock.c.actor_id == other.id, UserBlock.c.target_id == self.id))).scalar()
|
||||
return bool(block_exists)
|
||||
|
||||
def has_blocked_q(self, other_id: int):
|
||||
return select(UserBlock).where(UserBlock.c.actor_id == self.id, UserBlock.c.target_id == other_id).exists()
|
||||
|
||||
def blocked_by_q(self, other_id: int):
|
||||
return select(UserBlock).where(UserBlock.c.actor_id == other_id, UserBlock.c.target_id == self.id).exists()
|
||||
|
||||
@not_implemented()
|
||||
def end_friendship(self, other: User):
|
||||
|
|
@ -268,10 +302,10 @@ class User(Base):
|
|||
|
||||
def has_subscriber(self, other: User) -> bool:
|
||||
# TODO implement in 0.5
|
||||
return False #bool(db.session.execute(select(Friendship).where(...)).scalar())
|
||||
return False #bool(session.execute(select(Friendship).where(...)).scalar())
|
||||
|
||||
@classmethod
|
||||
def has_not_blocked(cls, actor, target):
|
||||
def has_not_blocked(cls, actor: int, target: int):
|
||||
"""
|
||||
Filter out a content if the author has blocked current user. Returns a query.
|
||||
|
||||
|
|
@ -285,26 +319,34 @@ class User(Base):
|
|||
qq= ~select(UserBlock).where(UserBlock.c.actor_id == actor_id, UserBlock.c.target_id == target_id).exists()
|
||||
return qq
|
||||
|
||||
def recompute_karma(self):
|
||||
async def recompute_karma(self):
|
||||
"""
|
||||
Recompute karma as of 0.4.0 karma handling
|
||||
"""
|
||||
async with db as session:
|
||||
c = 0
|
||||
c += db.session.execute(select(func.count('*')).select_from(Post).where(Post.author == self)).scalar()
|
||||
c += db.session.execute(select(func.count('*')).select_from(PostUpvote).join(Post).where(Post.author == self, PostUpvote.c.is_downvote == False)).scalar()
|
||||
c -= db.session.execute(select(func.count('*')).select_from(PostUpvote).join(Post).where(Post.author == self, PostUpvote.c.is_downvote == True)).scalar()
|
||||
|
||||
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
|
||||
|
||||
@timed_cache(60)
|
||||
def strike_count(self) -> int:
|
||||
return db.session.execute(select(func.count('*')).select_from(UserStrike).where(UserStrike.user_id == self.id)).scalar()
|
||||
return c
|
||||
|
||||
def moderates(self, gu: Guild) -> bool:
|
||||
## TODO are coroutines cacheable?
|
||||
@timed_cache(60, async_=True)
|
||||
async def strike_count(self) -> int:
|
||||
async with db as session:
|
||||
return (await session.execute(select(func.count('*')).select_from(UserStrike).where(UserStrike.user_id == self.id))).scalar()
|
||||
|
||||
async def moderates(self, gu: Guild) -> bool:
|
||||
async with db as session:
|
||||
## owner
|
||||
if gu.owner_id == self.id:
|
||||
return True
|
||||
## admin or global mod
|
||||
if self.is_administrator:
|
||||
return True
|
||||
memb = db.session.execute(select(Member).where(Member.user_id == self.id, Member.guild_id == gu.id)).scalar()
|
||||
memb = (await session.execute(select(Member).where(Member.user_id == self.id, Member.guild_id == gu.id))).scalar()
|
||||
|
||||
if memb is None:
|
||||
return False
|
||||
|
|
@ -312,6 +354,29 @@ class User(Base):
|
|||
|
||||
## TODO check banship?
|
||||
|
||||
@makelist
|
||||
def badges(self, /):
|
||||
if self.is_administrator:
|
||||
yield 'administrator'
|
||||
|
||||
badges: Callable[[], list[str]]
|
||||
|
||||
@classmethod
|
||||
async def get_by_username(cls, name: str):
|
||||
"""
|
||||
Get a user by its username,
|
||||
"""
|
||||
user_q = select(User).where(User.username == name)
|
||||
try:
|
||||
if current_user:
|
||||
user_q = user_q.where(~select(UserBlock).where(UserBlock.c.target_id == current_user.id).exists())
|
||||
except Exception as e:
|
||||
logger.error(f'{e}')
|
||||
|
||||
async with db as session:
|
||||
user = (await session.execute(user_q)).scalar()
|
||||
return user
|
||||
|
||||
# UserBlock table is at the top !!
|
||||
|
||||
## END User
|
||||
|
|
@ -346,62 +411,76 @@ class Guild(Base):
|
|||
def handle(self):
|
||||
return f'+{self.name}'
|
||||
|
||||
def subscriber_count(self):
|
||||
return db.session.execute(select(func.count('*')).select_from(Member).where(Member.guild == self, Member.is_subscribed == True)).scalar()
|
||||
async def subscriber_count(self):
|
||||
async with db as session:
|
||||
count = (await session.execute(select(func.count('*')).select_from(Member).where(Member.guild == self, Member.is_subscribed == True))).scalar()
|
||||
return count
|
||||
|
||||
# utilities
|
||||
owner = relationship(User, foreign_keys=owner_id)
|
||||
posts = relationship('Post', back_populates='guild')
|
||||
owner = relationship(User, foreign_keys=owner_id, lazy='selectin')
|
||||
posts = relationship('Post', back_populates='guild', lazy='selectin')
|
||||
|
||||
def has_subscriber(self, other: User) -> bool:
|
||||
if other is None or not other.is_authenticated:
|
||||
return False
|
||||
return bool(db.session.execute(select(Member).where(Member.user_id == other.id, Member.guild_id == self.id, Member.is_subscribed == True)).scalar())
|
||||
async def post_count(self):
|
||||
async with db as session:
|
||||
return (await session.execute(select(func.count('*')).select_from(Post).where(Post.guild == self))).scalar()
|
||||
|
||||
def has_exiled(self, other: User) -> bool:
|
||||
if other is None or not other.is_authenticated:
|
||||
async def has_subscriber(self, other: User) -> bool:
|
||||
if not want_User(other, var_name='other', prefix='Guild.has_subscriber()'):
|
||||
return False
|
||||
u = db.session.execute(select(Member).where(Member.user_id == other.id, Member.guild_id == self.id)).scalar()
|
||||
async with db as session:
|
||||
sub_ex = (await session.execute(select(Member).where(Member.user_id == other.id, Member.guild_id == self.id, Member.is_subscribed == True))).scalar()
|
||||
return bool(sub_ex)
|
||||
|
||||
async def has_exiled(self, other: User) -> bool:
|
||||
if not want_User(other, var_name='other', prefix='Guild.has_exiled()'):
|
||||
return False
|
||||
async with db as session:
|
||||
u = (await session.execute(select(Member).where(Member.user_id == other.id, Member.guild_id == self.id))).scalar()
|
||||
return u.is_banned if u else False
|
||||
|
||||
def allows_posting(self, other: User) -> bool:
|
||||
if self.owner is None:
|
||||
async def allows_posting(self, other: User) -> bool:
|
||||
async with db as session:
|
||||
# control owner_id instead of owner: the latter causes MissingGreenletError
|
||||
if self.owner_id is None:
|
||||
return False
|
||||
if other.is_disabled:
|
||||
return False
|
||||
mem: Member | None = db.session.execute(select(Member).where(Member.user_id == other.id, Member.guild_id == self.id)).scalar() if other else None
|
||||
mem: Member | None = (await session.execute(select(Member).where(Member.user_id == other.id, Member.guild_id == self.id))).scalar()
|
||||
if mem and mem.is_banned:
|
||||
return False
|
||||
if other.moderates(self):
|
||||
if await other.moderates(self):
|
||||
return True
|
||||
if self.is_restricted:
|
||||
return (mem and mem.is_approved)
|
||||
return True
|
||||
|
||||
|
||||
def moderators(self):
|
||||
if self.owner:
|
||||
yield ModeratorInfo(self.owner, True)
|
||||
for mem in db.session.execute(select(Member).where(Member.guild_id == self.id, Member.is_moderator == True)).scalars():
|
||||
async def moderators(self):
|
||||
async with db as session:
|
||||
if self.owner_id:
|
||||
owner = (await session.execute(select(User).where(User.id == self.owner_id))).scalar()
|
||||
yield ModeratorInfo(owner, True)
|
||||
for mem in (await session.execute(select(Member).where(Member.guild_id == self.id, Member.is_moderator == True))).scalars():
|
||||
if mem.user != self.owner and not mem.is_banned:
|
||||
yield ModeratorInfo(mem.user, False)
|
||||
|
||||
def update_member(self, u: User | Member, /, **values):
|
||||
async def update_member(self, u: User | Member, /, **values):
|
||||
if isinstance(u, User):
|
||||
m = db.session.execute(select(Member).where(Member.user_id == u.id, Member.guild_id == self.id)).scalar()
|
||||
async with db as session:
|
||||
m = (await session.execute(select(Member).where(Member.user_id == u.id, Member.guild_id == self.id))).scalar()
|
||||
if m is None:
|
||||
m = db.session.execute(insert(Member).values(
|
||||
m = (await session.execute(insert(Member).values(
|
||||
guild_id = self.id,
|
||||
user_id = u.id,
|
||||
**values
|
||||
).returning(Member)).scalar()
|
||||
).returning(Member))).scalar()
|
||||
if m is None:
|
||||
raise RuntimeError
|
||||
return m
|
||||
else:
|
||||
m = u
|
||||
if len(values):
|
||||
db.session.execute(update(Member).where(Member.user_id == u.id, Member.guild_id == self.id).values(**values))
|
||||
async with db as session:
|
||||
session.execute(update(Member).where(Member.user_id == u.id, Member.guild_id == self.id).values(**values))
|
||||
return m
|
||||
|
||||
|
||||
|
|
@ -433,9 +512,9 @@ class Member(Base):
|
|||
banned_until = Column(DateTime, nullable=True)
|
||||
banned_message = Column(String(256), nullable=True)
|
||||
|
||||
user = relationship(User, primaryjoin = lambda: User.id == Member.user_id)
|
||||
guild = relationship(Guild)
|
||||
banned_by = relationship(User, primaryjoin = lambda: User.id == Member.banned_by_id)
|
||||
user = relationship(User, primaryjoin = lambda: User.id == Member.user_id, lazy='selectin')
|
||||
guild = relationship(Guild, lazy='selectin')
|
||||
banned_by = relationship(User, primaryjoin = lambda: User.id == Member.banned_by_id, lazy='selectin')
|
||||
|
||||
@property
|
||||
def is_banned(self):
|
||||
|
|
@ -474,10 +553,14 @@ class Post(Base):
|
|||
removed_reason = Column(SmallInteger, nullable=True)
|
||||
|
||||
# utilities
|
||||
author: Relationship[User] = relationship("User", lazy='selectin', foreign_keys=[author_id])#, back_populates="posts")
|
||||
author: Relationship[User] = relationship("User", foreign_keys=[author_id], lazy='selectin')#, back_populates="posts")
|
||||
guild: Relationship[Guild] = relationship("Guild", back_populates="posts", lazy='selectin')
|
||||
comments = relationship("Comment", back_populates="parent_post")
|
||||
upvoters = relationship("User", secondary=PostUpvote, back_populates='upvoted_posts')
|
||||
comments = relationship("Comment", back_populates="parent_post", lazy='selectin')
|
||||
upvoters = relationship("User", secondary=PostUpvote, back_populates='upvoted_posts', lazy='selectin')
|
||||
|
||||
async def comment_count(self):
|
||||
async with db as session:
|
||||
return (await session.execute(select(func.count('*')).select_from(Comment).where(Comment.parent_post == self))).scalar()
|
||||
|
||||
def topic_or_user(self) -> Guild | User:
|
||||
return self.guild or self.author
|
||||
|
|
@ -489,33 +572,41 @@ class Post(Base):
|
|||
def generate_slug(self) -> str:
|
||||
return "slugify.slugify(self.title, max_length=64)"
|
||||
|
||||
def upvotes(self) -> int:
|
||||
return (db.session.execute(select(func.count('*')).select_from(PostUpvote).where(PostUpvote.c.post_id == self.id, PostUpvote.c.is_downvote == False)).scalar()
|
||||
- db.session.execute(select(func.count('*')).select_from(PostUpvote).where(PostUpvote.c.post_id == self.id, PostUpvote.c.is_downvote == True)).scalar())
|
||||
async def upvotes(self) -> int:
|
||||
async with db as session:
|
||||
upv = (await session.execute(select(func.count('*')).select_from(PostUpvote).where(PostUpvote.c.post_id == self.id, PostUpvote.c.is_downvote == False))).scalar()
|
||||
dwv = (await session.execute(select(func.count('*')).select_from(PostUpvote).where(PostUpvote.c.post_id == self.id, PostUpvote.c.is_downvote == True))).scalar()
|
||||
return upv - dwv
|
||||
|
||||
def upvoted_by(self, user: User | AnonymousUserMixin | None):
|
||||
if not user or not user.is_authenticated:
|
||||
async def upvoted_by(self, user: User | None):
|
||||
if not want_User(user, var_name='user', prefix='Post.upvoted_by()'):
|
||||
return 0
|
||||
v: PostUpvote | None = db.session.execute(select(PostUpvote.c).where(PostUpvote.c.voter_id == user.id, PostUpvote.c.post_id == self.id)).fetchone()
|
||||
if v:
|
||||
if v.is_downvote:
|
||||
async with db as session:
|
||||
v = (await session.execute(select(PostUpvote.c.is_downvote).where(PostUpvote.c.voter_id == user.id, PostUpvote.c.post_id == self.id))).fetchone()
|
||||
if v is None:
|
||||
return 0
|
||||
if v == (True,):
|
||||
return -1
|
||||
if v == (False,):
|
||||
return 1
|
||||
logger.warning(f'unexpected value: {v}')
|
||||
return 0
|
||||
|
||||
def top_level_comments(self, limit=None):
|
||||
return db.session.execute(select(Comment).where(Comment.parent_comment == None, Comment.parent_post == self).order_by(Comment.created_at.desc()).limit(limit)).scalars()
|
||||
async def top_level_comments(self, limit=None):
|
||||
async with db as session:
|
||||
return (await session.execute(select(Comment).where(Comment.parent_comment == None, Comment.parent_post == self).order_by(Comment.created_at.desc()).limit(limit))).scalars()
|
||||
|
||||
def report_url(self) -> str:
|
||||
return f'/report/post/{Snowflake(self.id):l}'
|
||||
|
||||
def report_count(self) -> int:
|
||||
return db.session.execute(select(func.count('*')).select_from(PostReport).where(PostReport.target_id == self.id, ~PostReport.update_status.in_((1, 2)))).scalar()
|
||||
async def report_count(self) -> int:
|
||||
async with db as session: return (await session.execute(select(func.count('*')).select_from(PostReport).where(PostReport.target_id == self.id, ~PostReport.update_status.in_((1, 2))))).scalar()
|
||||
|
||||
@classmethod
|
||||
@timed_cache(1800)
|
||||
def count(cls):
|
||||
return db.session.execute(select(func.count('*')).select_from(cls)).scalar()
|
||||
@timed_cache(1800, async_=True)
|
||||
async def count(cls):
|
||||
async with db as session:
|
||||
return (await session.execute(select(func.count('*')).select_from(cls))).scalar()
|
||||
|
||||
@property
|
||||
def is_removed(self) -> bool:
|
||||
|
|
@ -527,7 +618,8 @@ class Post(Base):
|
|||
|
||||
@classmethod
|
||||
def visible_by(cls, user_id: int | None):
|
||||
return or_(Post.author_id == user_id, Post.privacy.in_((0, 1)))
|
||||
return or_(Post.author_id == user_id, Post.privacy == 0)
|
||||
#return or_(Post.author_id == user_id, and_(Post.privacy.in_((0, 1)), ~Post.author.has_blocked_q(user_id)))
|
||||
|
||||
|
||||
class Comment(Base):
|
||||
|
|
@ -554,8 +646,8 @@ class Comment(Base):
|
|||
removed_by_id = Column(BigInteger, ForeignKey('freak_user.id', name='user_banner_id'), nullable=True)
|
||||
removed_reason = Column(SmallInteger, nullable=True)
|
||||
|
||||
author = relationship('User', foreign_keys=[author_id])#, back_populates='comments')
|
||||
parent_post: Relationship[Post] = relationship("Post", back_populates="comments", foreign_keys=[parent_post_id])
|
||||
author = relationship('User', foreign_keys=[author_id], lazy='selectin')#, back_populates='comments')
|
||||
parent_post: Relationship[Post] = relationship("Post", back_populates="comments", foreign_keys=[parent_post_id], lazy='selectin')
|
||||
parent_comment, child_comments = parent_children('comment', parent_remote_side=Wanted('id'))
|
||||
|
||||
def url(self):
|
||||
|
|
@ -564,8 +656,9 @@ class Comment(Base):
|
|||
def report_url(self) -> str:
|
||||
return f'/report/comment/{Snowflake(self.id):l}'
|
||||
|
||||
def report_count(self) -> int:
|
||||
return db.session.execute(select(func.count('*')).select_from(PostReport).where(PostReport.target_id == self.id, ~PostReport.update_status.in_((1, 2)))).scalar()
|
||||
async def report_count(self) -> int:
|
||||
async with db as session:
|
||||
return (await session.execute(select(func.count('*')).select_from(PostReport).where(PostReport.target_id == self.id, ~PostReport.update_status.in_((1, 2))))).scalar()
|
||||
|
||||
@property
|
||||
def is_removed(self) -> bool:
|
||||
|
|
@ -588,13 +681,14 @@ class PostReport(Base):
|
|||
created_at = Column(DateTime, server_default=func.current_timestamp())
|
||||
created_ip = Column(String(64), default=get_remote_addr, nullable=False)
|
||||
|
||||
author = relationship('User')
|
||||
author = relationship('User', lazy='selectin')
|
||||
|
||||
def target(self):
|
||||
async def target(self):
|
||||
async with db as session:
|
||||
if self.target_type == REPORT_TARGET_POST:
|
||||
return db.session.execute(select(Post).where(Post.id == self.target_id)).scalar()
|
||||
return (await session.execute(select(Post).where(Post.id == self.target_id))).scalar()
|
||||
elif self.target_type == REPORT_TARGET_COMMENT:
|
||||
return db.session.execute(select(Comment).where(Comment.id == self.target_id)).scalar()
|
||||
return (await session.execute(select(Comment).where(Comment.id == self.target_id))).scalar()
|
||||
else:
|
||||
return self.target_id
|
||||
|
||||
|
|
@ -616,8 +710,8 @@ class UserStrike(Base):
|
|||
issued_at = Column(DateTime, server_default=func.current_timestamp())
|
||||
issued_by_id = Column(BigInteger, ForeignKey('freak_user.id'), nullable=True)
|
||||
|
||||
user = relationship(User, primaryjoin= lambda: User.id == UserStrike.user_id)
|
||||
issued_by = relationship(User, primaryjoin= lambda: User.id == UserStrike.issued_by_id)
|
||||
user = relationship(User, primaryjoin= lambda: User.id == UserStrike.user_id, lazy='selectin')
|
||||
issued_by = relationship(User, primaryjoin= lambda: User.id == UserStrike.issued_by_id, lazy='selectin')
|
||||
|
||||
# PostUpvote table is at the top !!
|
||||
|
||||
|
|
|
|||
|
|
@ -1,24 +1,41 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
from flask import Blueprint, redirect, url_for
|
||||
from flask_restx import Resource
|
||||
from flask import abort
|
||||
from quart import Blueprint, redirect, url_for
|
||||
from quart_auth import current_user, login_required
|
||||
from quart_schema import QuartSchema, validate_request, validate_response
|
||||
from sqlalchemy import select
|
||||
from suou import Snowflake
|
||||
from suou.flask_sqlalchemy import require_auth
|
||||
from suou import Snowflake, deprecated, not_implemented, want_isodate
|
||||
|
||||
from suou.flask_restx import Api
|
||||
from suou.quart import add_rest
|
||||
|
||||
from ..models import Post, User, db
|
||||
from .. import UserLoader, app, app_config, __version__ as freak_version
|
||||
|
||||
rest_bp = Blueprint('rest', __name__, url_prefix='/v1')
|
||||
rest = Api(rest_bp)
|
||||
bp = Blueprint('rest', __name__, url_prefix='/v1')
|
||||
rest = add_rest(app, '/v1', '/ajax')
|
||||
|
||||
auth_required = require_auth(User, db)
|
||||
current_user: UserLoader
|
||||
|
||||
@rest.route('/nurupo')
|
||||
class Nurupo(Resource):
|
||||
def get(self):
|
||||
return dict(nurupo='ga')
|
||||
## TODO deprecate auth_required since it does not work
|
||||
from suou.flask_sqlalchemy import require_auth
|
||||
auth_required = deprecated('use login_required() and current_user instead')(require_auth(User, db))
|
||||
|
||||
@not_implemented()
|
||||
async def authenticated():
|
||||
pass
|
||||
|
||||
@bp.get('/nurupo')
|
||||
async def get_nurupo():
|
||||
return dict(ga=-1)
|
||||
|
||||
@bp.get('/health')
|
||||
async def health():
|
||||
return dict(
|
||||
version=freak_version,
|
||||
name = app_config.app_name
|
||||
)
|
||||
|
||||
## TODO coverage of REST is still partial, but it's planned
|
||||
## to get complete sooner or later
|
||||
|
|
@ -27,34 +44,44 @@ class Nurupo(Resource):
|
|||
## redirect, neither is able to get user injected.
|
||||
## Auth-based REST endpoints won't be fully functional until 0.6 in most cases
|
||||
|
||||
@rest.route('/user/@me')
|
||||
class UserInfoMe(Resource):
|
||||
@auth_required(required=True)
|
||||
def get(self, user: User):
|
||||
return redirect(url_for('rest.UserInfo', user.id)), 302
|
||||
|
||||
@rest.route('/user/<b32l:id>')
|
||||
class UserInfo(Resource):
|
||||
def get(self, id: int):
|
||||
@bp.get('/user/@me')
|
||||
@login_required
|
||||
async def get_user_me():
|
||||
return redirect(url_for(f'rest.user_get', current_user.id)), 302
|
||||
|
||||
@bp.get('/user/<b32l:id>')
|
||||
async def user_get(id: int):
|
||||
## TODO sanizize REST to make blocked users inaccessible
|
||||
u: User | None = db.session.execute(select(User).where(User.id == id)).scalar()
|
||||
async with db as session:
|
||||
u: User | None = (await session.execute(select(User).where(User.id == id))).scalar()
|
||||
if u is None:
|
||||
return dict(error='User not found'), 404
|
||||
uj = dict(
|
||||
id = f'{Snowflake(u.id):l}',
|
||||
username = u.username,
|
||||
display_name = u.display_name,
|
||||
joined_at = u.joined_at.isoformat('T'),
|
||||
joined_at = want_isodate(u.joined_at),
|
||||
karma = u.karma,
|
||||
age = u.age()
|
||||
age = u.age(),
|
||||
biography=u.biography,
|
||||
badges = u.badges()
|
||||
)
|
||||
return dict(users={f'{Snowflake(id):l}': uj})
|
||||
|
||||
@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
|
||||
|
||||
@rest.route('/post/<b32l:id>')
|
||||
class SinglePost(Resource):
|
||||
def get(self, id: int):
|
||||
p: Post | None = db.session.execute(select(Post).where(Post.id == id)).scalar()
|
||||
|
||||
@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:
|
||||
return dict(error='Not found'), 404
|
||||
pj = dict(
|
||||
|
|
|
|||
|
|
@ -2,12 +2,8 @@
|
|||
|
||||
|
||||
from typing import Iterable
|
||||
from flask import flash, g
|
||||
from sqlalchemy import Column, Select, select, or_
|
||||
|
||||
from .models import Guild, User, db
|
||||
|
||||
|
||||
class SearchQuery:
|
||||
keywords: Iterable[str]
|
||||
|
||||
|
|
@ -27,24 +23,3 @@ class SearchQuery:
|
|||
return sq
|
||||
|
||||
|
||||
def find_guild_or_user(name: str) -> str | None:
|
||||
"""
|
||||
Used in 404 error handler.
|
||||
|
||||
Returns an URL to redirect or None for no redirect.
|
||||
"""
|
||||
|
||||
if hasattr(g, 'no_user'):
|
||||
return None
|
||||
|
||||
gu = db.session.execute(select(Guild).where(Guild.name == name)).scalar()
|
||||
if gu is not None:
|
||||
flash(f'There is nothing at /{name}. Luckily, a guild with name {gu.handle()} happens to exist. Next time, remember to add + before!')
|
||||
return gu.url()
|
||||
|
||||
user = db.session.execute(select(User).where(User.username == name)).scalar()
|
||||
if user is not None:
|
||||
flash(f'There is nothing at /{name}. Luckily, a user named {user.handle()} happens to exist. Next time, remember to add @ before!')
|
||||
return user.url()
|
||||
|
||||
return None
|
||||
|
|
@ -11,20 +11,20 @@
|
|||
<div class="content">
|
||||
<h2>Stats</h2>
|
||||
<ul>
|
||||
<li>No. 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 posts: <strong>{{ post_count }}</strong></li>
|
||||
<li># of active users (posters in the last 30 days): <strong>{{ user_count }}</strong></li>
|
||||
</ul>
|
||||
|
||||
<h2>Software versions</h2>
|
||||
<ul>
|
||||
<li><strong>Python</strong>: {{ python_version }}</strong></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>
|
||||
</ul>
|
||||
|
||||
<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 %}
|
||||
<h2>Legal Contacts</h2>
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
{%- if u.is_administrator %}
|
||||
<span>(Admin)</span>
|
||||
{% endif -%}
|
||||
{% if u == current_user %}
|
||||
{% if u == current_user.user %}
|
||||
<span>(You)</span>
|
||||
{% endif -%}
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@
|
|||
This Service is available "AS IS", with NO WARRANTY, explicit or implied.
|
||||
Sakuragasaki46 is NOT legally liable for Your use of the Service.
|
||||
This service is age-restricted; do not access if underage.
|
||||
More info: https://{{ domain_name }}/terms
|
||||
More info: https://{{ server_name }}/terms
|
||||
-->
|
||||
<meta name="og:site_name" content="{{ app_name }}" />
|
||||
<meta name="generator" content="{{ app_name }} {{ app_version }}" />
|
||||
|
|
@ -25,7 +25,7 @@
|
|||
<link rel="icon" href="/favicon.ico" />
|
||||
<script src="{{ jquery_url }}"></script>
|
||||
</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">
|
||||
<h1><a href="/">{{ app_name }}</a></h1>
|
||||
<div class="metanav">
|
||||
|
|
@ -44,9 +44,9 @@
|
|||
{% endif %}
|
||||
{% if g.no_user %}
|
||||
<!-- no user -->
|
||||
{% elif current_user.is_authenticated %}
|
||||
{% elif current_user %}
|
||||
<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') }}
|
||||
<span>New post</span>
|
||||
</a>
|
||||
|
|
@ -81,6 +81,7 @@
|
|||
{% for message in get_flashed_messages() %}
|
||||
<div class="flash card">{{ message }}</div>
|
||||
{% 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 %}
|
||||
<div class="content-header">
|
||||
{% block heading %}{% endblock %}
|
||||
|
|
@ -104,7 +105,7 @@
|
|||
<li><a href="https://github.com/sakuragasaki46/freak">GitHub</a></li>
|
||||
</ul>
|
||||
</footer>
|
||||
{% if current_user and current_user.is_authenticated %}
|
||||
{% if current_user %}
|
||||
<footer class="mobile-nav mobileonly">
|
||||
<ul>
|
||||
<li><a href="/" title="Homepage">{{ icon('home') }}</a></li>
|
||||
|
|
|
|||
|
|
@ -75,7 +75,7 @@
|
|||
<section class="card">
|
||||
<h2>Management</h2>
|
||||
<!-- 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>
|
||||
<label>
|
||||
Add user as moderator:
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ disabled=""
|
|||
{% if current_user.is_authenticated %}
|
||||
{% if current_user.is_disabled %}
|
||||
<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>
|
||||
{% elif p.is_locked %}
|
||||
<div class="centered">Comments are closed</div>
|
||||
|
|
|
|||
|
|
@ -13,8 +13,8 @@
|
|||
- <time datetime="{{ p.created_at.isoformat('T') }}">{{ p.created_at.strftime('%B %-d, %Y at %H:%M') }}</time>
|
||||
</div>
|
||||
<div class="message-stats">
|
||||
{{ feed_upvote(p.id, p.upvotes(), p.upvoted_by(current_user)) }}
|
||||
{{ comment_count(p.comments | count) }}
|
||||
{{ feed_upvote(p.id, p.upvotes(), p.upvoted_by(current_user.user)) }}
|
||||
{{ comment_count(p.comment_count()) }}
|
||||
</div>
|
||||
|
||||
<div class="message-content shorten">
|
||||
|
|
@ -53,12 +53,12 @@
|
|||
{% call callout('delete') %}<i>Removed comment</i>{% endcall %}
|
||||
{% else %}
|
||||
<div class="message-meta">
|
||||
{% if comment.author %}
|
||||
{% if comment.author_id %}
|
||||
<a href="{{ comment.author.url() }}">{{ comment.author.handle() }}</a>
|
||||
{% else %}
|
||||
<i>deleted account</i>
|
||||
{% 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>
|
||||
{% endif %}
|
||||
{# TODO add is_distinguished i.e. official comment #}
|
||||
|
|
@ -70,7 +70,7 @@
|
|||
{{ comment.text_content | to_markdown }}
|
||||
</div>
|
||||
<ul class="message-options inline">
|
||||
{% if comment.author == current_user %}
|
||||
{% if comment.author_id == current_user.id %}
|
||||
{# TODO add comment edit link #}
|
||||
{% else %}
|
||||
<li><a href="{{ comment.report_url() }}">{{ icon('report') }} Report</a></li>
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
<ul>
|
||||
<li><i class="icon icon-info" style="font-size:inherit"></i> {{ gu.description }}</li>
|
||||
<li>
|
||||
<strong>{{ gu.posts | count }}</strong> posts -
|
||||
<strong>{{ gu.post_count() }}</strong> posts -
|
||||
<strong>{{ gu.subscriber_count() }}</strong> subscribers
|
||||
</li>
|
||||
</ul>
|
||||
|
|
@ -17,12 +17,12 @@
|
|||
{% if current_user.moderates(gu) %}
|
||||
<a href="{{ gu.url() }}/settings"><button class="card">{{ icon('settings') }} Mod Tools</button></a>
|
||||
{% endif %}
|
||||
{{ subscribe_button(gu, gu.has_subscriber(current_user)) }}
|
||||
{% if not gu.owner %}
|
||||
{{ subscribe_button(gu, gu.has_subscriber(current_user.user)) }}
|
||||
{% if not gu.owner_id %}
|
||||
<aside class="card">
|
||||
<p class="centered">{{ gu.handle() }} is currently unmoderated</p>
|
||||
</aside>
|
||||
{% elif gu.has_exiled(current_user) %}
|
||||
{% elif gu.has_exiled(current_user.user) %}
|
||||
<aside class="card">
|
||||
<p class="centered">Moderator list is hidden because you are banned.</p>
|
||||
<!-- TODO appeal button -->
|
||||
|
|
@ -58,11 +58,11 @@
|
|||
{% endif %}
|
||||
</ul>
|
||||
</aside>
|
||||
{% if user == current_user %}
|
||||
{% if user == current_user.user %}
|
||||
<a href="/settings"><button class="card">{{ icon('settings') }} Settings</button></a>
|
||||
{% elif current_user.is_authenticated %}
|
||||
{{ 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 %}
|
||||
<aside class="card">
|
||||
<p><a href="/login">Log in</a> to subscribe and interact with {{ user.handle() }}</p>
|
||||
|
|
@ -75,9 +75,9 @@
|
|||
<h3>Top Communities</h3>
|
||||
<ul>
|
||||
{% 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 %}
|
||||
{% if current_user and current_user.is_authenticated and current_user.can_create_community() %}
|
||||
{% if current_user and current_user.can_create_community() %}
|
||||
<li>Can’t find your community? <a href="/createcommunity">Create a new one.</a></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
|
|
|
|||
|
|
@ -15,8 +15,8 @@
|
|||
<div>
|
||||
<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 %}
|
||||
{% if (p.comments | 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 %}
|
||||
{% if (p.comment_count()) %}
|
||||
{% 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 %}
|
||||
</div>
|
||||
<div>
|
||||
|
|
|
|||
|
|
@ -54,11 +54,11 @@
|
|||
</div>
|
||||
</div>
|
||||
<div class="message-stats">
|
||||
{{ feed_upvote(p.id, p.upvotes(), p.upvoted_by(current_user)) }}
|
||||
{{ comment_count(p.comments | count) }}
|
||||
{{ feed_upvote(p.id, p.upvotes(), p.upvoted_by(current_user.user)) }}
|
||||
{{ comment_count(p.comment_count()) }}
|
||||
</div>
|
||||
<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>
|
||||
{% else %}
|
||||
<li><a href="{{ p.report_url() }}"><i class="icon icon-report"></i> Report</a></li>
|
||||
|
|
|
|||
|
|
@ -5,7 +5,10 @@
|
|||
|
||||
{% block content %}
|
||||
<div class="content">
|
||||
{# If you host your own instance, rememmber to change Terms to fit your own purposes! #}
|
||||
{% filter to_markdown %}
|
||||
|
||||
|
||||
# Terms of Service
|
||||
|
||||
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
|
||||
|
||||
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"),
|
||||
listed in detail in [Privacy Policy](/policies/privacy.html).
|
||||
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@
|
|||
{% endblock %}
|
||||
|
||||
{% 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) }}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -5,8 +5,10 @@ import math
|
|||
import os
|
||||
import time
|
||||
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]:
|
||||
if now is None:
|
||||
now = datetime.date.today()
|
||||
|
|
@ -19,6 +21,7 @@ def get_remote_addr():
|
|||
return request.headers.getlist('X-Forwarded-For')[0]
|
||||
return request.remote_addr
|
||||
|
||||
@deprecated('replaced by suou.timed_cache()')
|
||||
def timed_cache(ttl: int, maxsize: int = 128, typed: bool = False):
|
||||
def decorator(func):
|
||||
start_time = None
|
||||
|
|
@ -39,7 +42,13 @@ def timed_cache(ttl: int, maxsize: int = 128, typed: bool = False):
|
|||
def is_b32l(username: str) -> bool:
|
||||
return re.fullmatch(r'[a-z2-7]+', username)
|
||||
|
||||
def twocolon_list(s: str | None) -> list[str]:
|
||||
if not s:
|
||||
return []
|
||||
return [x.strip() for x in s.split('::')]
|
||||
twocolon_list = deprecated('import from suou instead')(_twocolon_list)
|
||||
|
||||
async def get_request_form() -> dict:
|
||||
"""
|
||||
Get the request form as HTTP x-www-form-urlencoded dict
|
||||
|
||||
NEW 0.5.0
|
||||
"""
|
||||
return dict(await request.form)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,27 +1,32 @@
|
|||
|
||||
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
|
||||
|
||||
bp = Blueprint('about', __name__)
|
||||
|
||||
@bp.route('/about/')
|
||||
def about():
|
||||
return render_template('about.html',
|
||||
flask_version=flask_version,
|
||||
async def about():
|
||||
return await render_template('about.html',
|
||||
quart_version=quart_version,
|
||||
sa_version=sa_version,
|
||||
python_version=sys.version.split()[0]
|
||||
)
|
||||
|
||||
@bp.route('/terms/')
|
||||
def terms():
|
||||
return render_template('terms.html')
|
||||
async def terms():
|
||||
return await render_template('terms.html')
|
||||
|
||||
@bp.route('/privacy/')
|
||||
def privacy():
|
||||
return render_template('privacy.html')
|
||||
async def privacy():
|
||||
return await render_template('privacy.html')
|
||||
|
||||
@bp.route('/rules/')
|
||||
def rules():
|
||||
return render_template('rules.html')
|
||||
async def rules():
|
||||
return await render_template('rules.html')
|
||||
|
||||
|
|
|
|||
|
|
@ -1,63 +1,108 @@
|
|||
|
||||
|
||||
from __future__ import annotations
|
||||
import os, sys
|
||||
import enum
|
||||
import logging
|
||||
import sys
|
||||
import re
|
||||
import datetime
|
||||
from typing import Mapping
|
||||
from flask import Blueprint, abort, render_template, request, redirect, flash
|
||||
from flask_login import login_required, login_user, logout_user, current_user
|
||||
from quart import Blueprint, render_template, request, redirect, flash
|
||||
from quart_auth import AuthUser, login_required, login_user, logout_user, current_user
|
||||
from suou.functools import deprecated
|
||||
from werkzeug.exceptions import Forbidden
|
||||
|
||||
from .. import UserLoader
|
||||
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 werkzeug.security import generate_password_hash
|
||||
|
||||
current_user: User
|
||||
current_user: UserLoader
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
bp = Blueprint('accounts', __name__)
|
||||
|
||||
@bp.route('/login', methods=['GET', 'POST'])
|
||||
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()
|
||||
class LoginStatus(enum.Enum):
|
||||
SUCCESS = 0
|
||||
ERROR = 1
|
||||
SUSPENDED = 2
|
||||
PASS_EXPIRED = 3
|
||||
|
||||
if user and '$' not in user.passhash:
|
||||
flash('You need to reset your password following the procedure.')
|
||||
return render_template('login.html')
|
||||
elif not user or not user.check_password(password):
|
||||
flash('Invalid username or password')
|
||||
return render_template('login.html')
|
||||
elif not user.is_active:
|
||||
flash('Your account is suspended')
|
||||
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
|
||||
|
||||
|
||||
@bp.get('/login')
|
||||
async def login():
|
||||
return await render_template('login.html')
|
||||
|
||||
@bp.post('/login')
|
||||
async def post_login():
|
||||
form = await get_request_form()
|
||||
# TODO schema validator
|
||||
username: str = form['username']
|
||||
password: str = form['password']
|
||||
if '@' in username:
|
||||
user_q = select(User).where(User.email == username)
|
||||
else:
|
||||
remember_for = int(request.form.get('remember', 0))
|
||||
user_q = select(User).where(User.username == username)
|
||||
|
||||
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(user, remember=True, duration=datetime.timedelta(days=remember_for))
|
||||
login_user(UserLoader(user.get_id()), remember=True)
|
||||
else:
|
||||
login_user(user)
|
||||
login_user(UserLoader(user.get_id()))
|
||||
return redirect(request.args.get('next', '/'))
|
||||
return render_template('login.html')
|
||||
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')
|
||||
def logout():
|
||||
async def logout():
|
||||
logout_user()
|
||||
flash('Logged out. Come back soon~')
|
||||
await flash('Logged out. Come back soon~')
|
||||
return redirect(request.args.get('next','/'))
|
||||
|
||||
## XXX temp
|
||||
@deprecated('no good use')
|
||||
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()
|
||||
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):
|
||||
# block bot attempt to register
|
||||
|
|
@ -68,61 +113,75 @@ def validate_register_form() -> dict:
|
|||
|
||||
except ValueError:
|
||||
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']):
|
||||
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.')
|
||||
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
|
||||
|
||||
if _currently_logged_in() and not request.form.get('confirm_another'):
|
||||
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 form.get('confirm_another'):
|
||||
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.')
|
||||
|
||||
return f
|
||||
|
||||
|
||||
@bp.route('/register', methods=['GET', 'POST'])
|
||||
def register():
|
||||
if request.method == 'POST' and request.form['username']:
|
||||
class RegisterStatus(enum.Enum):
|
||||
SUCCESS = 0
|
||||
ERROR = 1
|
||||
USERNAME_TAKEN = 2
|
||||
IP_BANNED = 3
|
||||
|
||||
|
||||
@bp.post('/register')
|
||||
async def register_post():
|
||||
try:
|
||||
user_data = validate_register_form()
|
||||
user_data = await validate_register_form()
|
||||
except ValueError as e:
|
||||
if e.args:
|
||||
flash(e.args[0])
|
||||
return render_template('register.html')
|
||||
await flash(e.args[0])
|
||||
return await render_template('register.html')
|
||||
|
||||
try:
|
||||
db.session.execute(insert(User).values(**user_data))
|
||||
async with db as session:
|
||||
await session.execute(insert(User).values(**user_data))
|
||||
|
||||
db.session.commit()
|
||||
|
||||
flash('Account created successfully. You can now log in.')
|
||||
await flash('Account created successfully. You can now log in.')
|
||||
return redirect(request.args.get('next', '/'))
|
||||
except Exception as e:
|
||||
sys.excepthook(*sys.exc_info())
|
||||
flash('Unable to create account (possibly your username is already taken)')
|
||||
return render_template('register.html')
|
||||
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}
|
||||
|
||||
@bp.route('/settings', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def settings():
|
||||
async def settings():
|
||||
if request.method == 'POST':
|
||||
form = await get_request_form()
|
||||
async with db as session:
|
||||
changes = False
|
||||
user = current_user
|
||||
color_scheme = COLOR_SCHEMES[request.form.get('color_scheme')] if 'color_scheme' in request.form else None
|
||||
color_theme = int(request.form.get('color_theme')) if 'color_theme' in request.form else None
|
||||
biography = request.form.get('biography')
|
||||
display_name = request.form.get('display_name')
|
||||
user = current_user.user
|
||||
color_scheme = COLOR_SCHEMES[form.get('color_scheme')] if 'color_scheme' in form else None
|
||||
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()
|
||||
|
|
@ -133,9 +192,9 @@ def settings():
|
|||
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!')
|
||||
session.add(user)
|
||||
session.commit()
|
||||
await flash('Changes saved!')
|
||||
|
||||
return render_template('usersettings.html')
|
||||
return await render_template('usersettings.html')
|
||||
|
||||
|
|
|
|||
|
|
@ -4,27 +4,30 @@ import datetime
|
|||
from functools import wraps
|
||||
from typing import Callable
|
||||
import warnings
|
||||
from flask import Blueprint, abort, redirect, render_template, request, url_for
|
||||
from flask_login import current_user
|
||||
from quart import Blueprint, abort, redirect, render_template, request, url_for
|
||||
from quart_auth import current_user
|
||||
from markupsafe import Markup
|
||||
from sqlalchemy import insert, select, update
|
||||
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
|
||||
|
||||
bp = Blueprint('admin', __name__)
|
||||
|
||||
current_user: User
|
||||
current_user: UserLoader
|
||||
|
||||
## TODO make admin interface
|
||||
|
||||
def admin_required(func: Callable):
|
||||
@wraps(func)
|
||||
def wrapper(**ka):
|
||||
user: User = current_user
|
||||
if not user.is_authenticated or not user.is_administrator:
|
||||
def wrapper(*a, **ka):
|
||||
user: User = current_user.user
|
||||
if not user or not user.is_administrator:
|
||||
abort(403)
|
||||
return func(**ka)
|
||||
return func(*a, **ka)
|
||||
return wrapper
|
||||
|
||||
|
||||
|
|
@ -61,7 +64,8 @@ def colorized_account_status_string(u: User):
|
|||
base += ' <span class="faint">{1}</span>'
|
||||
return Markup(base).format(t1, t2 + t3)
|
||||
|
||||
def remove_content(target, reason_code: int):
|
||||
async def remove_content(target, reason_code: int):
|
||||
async with db as session:
|
||||
if isinstance(target, Post):
|
||||
target.removed_at = datetime.datetime.now()
|
||||
target.removed_by_id = current_user.id
|
||||
|
|
@ -70,7 +74,7 @@ def remove_content(target, reason_code: int):
|
|||
target.removed_at = datetime.datetime.now()
|
||||
target.removed_by_id = current_user.id
|
||||
target.removed_reason = reason_code
|
||||
db.session.add(target)
|
||||
session.add(target)
|
||||
|
||||
def get_author(target) -> User | None:
|
||||
if isinstance(target, (Post, Comment)):
|
||||
|
|
@ -89,25 +93,26 @@ def get_content(target) -> str | None:
|
|||
REPORT_ACTIONS = {}
|
||||
|
||||
@additem(REPORT_ACTIONS, '1')
|
||||
def accept_report(target, source: PostReport):
|
||||
async def accept_report(target, source: PostReport):
|
||||
async with db as session:
|
||||
if source.is_critical():
|
||||
warnings.warn('attempted remove on a critical report case, striking instead', UserWarning)
|
||||
return strike_report(target, source)
|
||||
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
|
||||
db.session.add(source)
|
||||
db.session.commit()
|
||||
session.add(source)
|
||||
|
||||
|
||||
@additem(REPORT_ACTIONS, '2')
|
||||
def strike_report(target, source: PostReport):
|
||||
remove_content(target, source.reason_code)
|
||||
async def strike_report(target, source: PostReport):
|
||||
async with db as session:
|
||||
await remove_content(target, source.reason_code)
|
||||
|
||||
author = get_author(target)
|
||||
if author:
|
||||
db.session.execute(insert(UserStrike).values(
|
||||
session.execute(insert(UserStrike).values(
|
||||
user_id = author.id,
|
||||
target_type = TARGET_TYPES[type(target)],
|
||||
target_id = target.id,
|
||||
|
|
@ -121,22 +126,21 @@ def strike_report(target, source: PostReport):
|
|||
author.banned_reason = source.reason_code
|
||||
|
||||
source.update_status = REPORT_UPDATE_COMPLETE
|
||||
db.session.add(source)
|
||||
db.session.commit()
|
||||
session.add(source)
|
||||
|
||||
|
||||
@additem(REPORT_ACTIONS, '0')
|
||||
def reject_report(target, source: PostReport):
|
||||
async def reject_report(target, source: PostReport):
|
||||
async with db as session:
|
||||
source.update_status = REPORT_UPDATE_REJECTED
|
||||
db.session.add(source)
|
||||
db.session.commit()
|
||||
session.add(source)
|
||||
|
||||
|
||||
@additem(REPORT_ACTIONS, '3')
|
||||
def withhold_report(target, source: PostReport):
|
||||
async def withhold_report(target, source: PostReport):
|
||||
async with db as session:
|
||||
source.update_status = REPORT_UPDATE_ON_HOLD
|
||||
db.session.add(source)
|
||||
db.session.commit()
|
||||
session.add(source)
|
||||
|
||||
|
||||
@additem(REPORT_ACTIONS, '4')
|
||||
|
|
@ -148,71 +152,72 @@ def escalate_report(target, source: PostReport):
|
|||
|
||||
@bp.route('/admin/')
|
||||
@admin_required
|
||||
def homepage():
|
||||
return render_template('admin/admin_home.html')
|
||||
async def homepage():
|
||||
return await render_template('admin/admin_home.html')
|
||||
|
||||
@bp.route('/admin/reports/')
|
||||
@admin_required
|
||||
def reports():
|
||||
async def reports():
|
||||
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)
|
||||
|
||||
@bp.route('/admin/reports/<b32l:id>', methods=['GET', 'POST'])
|
||||
@admin_required
|
||||
def report_detail(id: int):
|
||||
report = db.session.execute(select(PostReport).where(PostReport.id == id)).scalar()
|
||||
async def report_detail(id: int):
|
||||
async with db as session:
|
||||
report = (await session.execute(select(PostReport).where(PostReport.id == id))).scalar()
|
||||
if report is None:
|
||||
abort(404)
|
||||
if request.method == 'POST':
|
||||
action = REPORT_ACTIONS[request.form['do']]
|
||||
action(report.target(), report)
|
||||
form = await get_request_form()
|
||||
action = REPORT_ACTIONS[form['do']]
|
||||
await action(report.target(), report)
|
||||
return redirect(url_for('admin.reports'))
|
||||
return render_template('admin/admin_report_detail.html', report=report,
|
||||
return await render_template('admin/admin_report_detail.html', report=report,
|
||||
report_reasons=REPORT_REASON_STRINGS)
|
||||
|
||||
@bp.route('/admin/strikes/')
|
||||
@admin_required
|
||||
def strikes():
|
||||
strike_list = db.paginate(select(UserStrike).order_by(UserStrike.id.desc()))
|
||||
return render_template('admin/admin_strikes.html',
|
||||
async def strikes():
|
||||
strike_list = await db.paginate(select(UserStrike).order_by(UserStrike.id.desc()))
|
||||
return await render_template('admin/admin_strikes.html',
|
||||
strike_list=strike_list, report_reasons=REPORT_REASON_STRINGS)
|
||||
|
||||
|
||||
@bp.route('/admin/users/')
|
||||
@admin_required
|
||||
def users():
|
||||
async def users():
|
||||
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)
|
||||
|
||||
@bp.route('/admin/users/<b32l:id>', methods=['GET', 'POST'])
|
||||
@admin_required
|
||||
def user_detail(id: int):
|
||||
u = db.session.execute(select(User).where(User.id == id)).scalar()
|
||||
async def user_detail(id: int):
|
||||
async with db as session:
|
||||
u = session.execute(select(User).where(User.id == id)).scalar()
|
||||
if u is None:
|
||||
abort(404)
|
||||
if request.method == 'POST':
|
||||
action = request.form['do']
|
||||
form = await get_request_form()
|
||||
action = form['do']
|
||||
if action == 'suspend':
|
||||
u.banned_at = datetime.datetime.now()
|
||||
u.banned_by_id = current_user.id
|
||||
u.banned_reason = REPORT_REASONS.get(request.form.get('reason'), 0)
|
||||
db.session.commit()
|
||||
u.banned_reason = REPORT_REASONS.get(form.get('reason'), 0)
|
||||
elif action == 'unsuspend':
|
||||
u.banned_at = None
|
||||
u.banned_by_id = None
|
||||
u.banned_until = None
|
||||
u.banned_reason = None
|
||||
db.session.commit()
|
||||
elif action == 'to_3d':
|
||||
u.banned_at = datetime.datetime.now()
|
||||
u.banned_until = datetime.datetime.now() + datetime.timedelta(days=3)
|
||||
u.banned_by_id = current_user.id
|
||||
u.banned_reason = REPORT_REASONS.get(request.form.get('reason'), 0)
|
||||
db.session.commit()
|
||||
u.banned_reason = REPORT_REASONS.get(form.get('reason'), 0)
|
||||
else:
|
||||
abort(400)
|
||||
strikes = db.session.execute(select(UserStrike).where(UserStrike.user_id == id).order_by(UserStrike.id.desc())).scalars()
|
||||
strikes = (await session.execute(select(UserStrike).where(UserStrike.user_id == id).order_by(UserStrike.id.desc()))).scalars()
|
||||
return render_template('admin/admin_user_detail.html', u=u,
|
||||
report_reasons=REPORT_REASON_STRINGS, account_status_string=colorized_account_status_string, strikes=strikes)
|
||||
|
|
|
|||
|
|
@ -2,20 +2,23 @@
|
|||
|
||||
import sys
|
||||
import datetime
|
||||
from flask import Blueprint, abort, redirect, flash, render_template, request, url_for
|
||||
from flask_login import current_user, login_required
|
||||
from quart import Blueprint, abort, redirect, flash, render_template, request, url_for
|
||||
from quart_auth import current_user, login_required
|
||||
from sqlalchemy import insert, select
|
||||
|
||||
from freak import UserLoader
|
||||
from freak.utils import get_request_form
|
||||
from ..models import User, db, Guild, Post
|
||||
|
||||
current_user: User
|
||||
current_user: UserLoader
|
||||
|
||||
bp = Blueprint('create', __name__)
|
||||
|
||||
def create_savepoint(
|
||||
async def create_savepoint(
|
||||
target = '', title = '', content = '',
|
||||
privacy = 0
|
||||
):
|
||||
return render_template('create.html',
|
||||
return await render_template('create.html',
|
||||
sv_target = target,
|
||||
sv_title = title,
|
||||
sv_content = content,
|
||||
|
|
@ -24,74 +27,78 @@ def create_savepoint(
|
|||
|
||||
@bp.route('/create/', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def create():
|
||||
user: User = current_user
|
||||
if request.method == 'POST' and 'title' in request.form:
|
||||
gname = request.form['to']
|
||||
title = request.form['title']
|
||||
text = request.form['text']
|
||||
privacy = int(request.form.get('privacy', '0'))
|
||||
async def create():
|
||||
user: User = current_user.user
|
||||
form = await get_request_form()
|
||||
if request.method == 'POST' and 'title' in form:
|
||||
gname = form['to']
|
||||
title = form['title']
|
||||
text = form['text']
|
||||
privacy = int(form.get('privacy', '0'))
|
||||
|
||||
async with db as session:
|
||||
if gname:
|
||||
guild: Guild | None = db.session.execute(select(Guild).where(Guild.name == gname)).scalar()
|
||||
guild: Guild | None = (await 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)
|
||||
await flash(f'Guild +{gname} not found or inaccessible')
|
||||
return await create_savepoint('', title, text, privacy)
|
||||
if guild.has_exiled(user):
|
||||
flash(f'You are banned from +{gname}')
|
||||
return create_savepoint('', title, text, privacy)
|
||||
await flash(f'You are banned from +{gname}')
|
||||
return await 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)
|
||||
await flash(f'You can\'t post on +{gname}')
|
||||
return await create_savepoint('', title, text, privacy)
|
||||
else:
|
||||
guild = None
|
||||
try:
|
||||
new_post: Post = db.session.execute(insert(Post).values(
|
||||
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)).fetchone()
|
||||
).returning(Post.id))).scalar()
|
||||
|
||||
db.session.commit()
|
||||
flash(f'Published on {guild.handle() if guild else user.handle()}')
|
||||
return redirect(url_for('detail.post_detail', id=new_post.id))
|
||||
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())
|
||||
flash('Unable to publish!')
|
||||
return create_savepoint(target=request.args.get('on',''))
|
||||
await flash('Unable to publish!')
|
||||
return await create_savepoint(target=request.args.get('on',''))
|
||||
|
||||
|
||||
@bp.route('/createguild/', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def createguild():
|
||||
async def createguild():
|
||||
if request.method == 'POST':
|
||||
user: User = current_user
|
||||
|
||||
if not user.can_create_community():
|
||||
flash('You are NOT allowed to create new guilds.')
|
||||
if not current_user.user.can_create_community():
|
||||
await flash('You are NOT allowed to create new guilds.')
|
||||
abort(403)
|
||||
|
||||
c_name = request.form['name']
|
||||
form = await get_request_form()
|
||||
|
||||
c_name = form['name']
|
||||
try:
|
||||
new_guild = db.session.execute(insert(Guild).values(
|
||||
async with db as session:
|
||||
new_guild = (await session.execute(insert(Guild).values(
|
||||
name = c_name,
|
||||
display_name = request.form.get('display_name', c_name),
|
||||
description = request.form['description'],
|
||||
owner_id = user.id
|
||||
).returning(Guild)).scalar()
|
||||
display_name = form.get('display_name', c_name),
|
||||
description = form['description'],
|
||||
owner_id = current_user.id
|
||||
).returning(Guild))).scalar()
|
||||
|
||||
if new_guild is None:
|
||||
raise RuntimeError('no returning')
|
||||
|
||||
db.session.commit()
|
||||
await session.commit()
|
||||
return redirect(new_guild.url())
|
||||
except Exception:
|
||||
sys.excepthook(*sys.exc_info())
|
||||
flash('Unable to create guild. It may already exist or you could not have permission to create new communities.')
|
||||
return render_template('createguild.html')
|
||||
await flash('Unable to create guild. It may already exist or you could not have permission to create new communities.')
|
||||
return await render_template('createguild.html')
|
||||
|
||||
@bp.route('/createcommunity/')
|
||||
def createcommunity_redirect():
|
||||
async def createcommunity_redirect():
|
||||
return redirect(url_for('create.createguild')), 301
|
||||
|
|
@ -1,31 +1,35 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
from flask import Blueprint, abort, flash, redirect, render_template, request
|
||||
from flask_login import current_user, login_required
|
||||
from quart import Blueprint, abort, flash, redirect, render_template, request
|
||||
from quart_auth import current_user, login_required
|
||||
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.route('/delete/post/<b32l:id>', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def delete_post(id: int):
|
||||
p = db.session.execute(select(Post).where(Post.id == id, Post.author == current_user)).scalar()
|
||||
async def delete_post(id: int):
|
||||
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:
|
||||
if p.author != current_user.user:
|
||||
abort(403)
|
||||
|
||||
pt = p.topic_or_user()
|
||||
|
||||
if request.method == 'POST':
|
||||
db.session.execute(delete(Post).where(Post.id == id, Post.author == current_user))
|
||||
db.session.commit()
|
||||
flash('Your post has been deleted')
|
||||
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)
|
||||
|
|
@ -1,95 +1,112 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Iterable
|
||||
from flask import Blueprint, abort, flash, request, redirect, render_template, url_for
|
||||
from flask_login import current_user
|
||||
from quart import Blueprint, abort, flash, request, redirect, render_template, url_for
|
||||
from quart_auth import current_user
|
||||
from sqlalchemy import insert, select
|
||||
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 ..algorithms import new_comments, user_timeline
|
||||
|
||||
current_user: UserLoader
|
||||
|
||||
bp = Blueprint('detail', __name__)
|
||||
|
||||
@bp.route('/@<username>')
|
||||
def user_profile(username):
|
||||
user = db.session.execute(select(User).where(User.username == username)).scalar()
|
||||
async def user_profile(username):
|
||||
async with db as session:
|
||||
user = (await session.execute(select(User).where(User.username == username))).scalar()
|
||||
|
||||
if user is None:
|
||||
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('/user/<username>')
|
||||
def user_profile_u(username: str):
|
||||
async def user_profile_u(username: str):
|
||||
if is_b32l(username):
|
||||
userid = int(Snowflake.from_b32l(username))
|
||||
user = db.session.execute(select(User).where(User.id == userid)).scalar()
|
||||
async with db as session:
|
||||
user = (await session.execute(select(User).where(User.id == userid))).scalar()
|
||||
if user is not None:
|
||||
username = user.username
|
||||
return redirect('/@' + username), 302
|
||||
|
||||
|
||||
@bp.route('/@<username>/')
|
||||
def user_profile_s(username):
|
||||
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:
|
||||
gu = p.guild
|
||||
if gu.has_exiled(current_user):
|
||||
flash(f'You have been banned from {gu.handle()}')
|
||||
if gu.has_exiled(current_user.user):
|
||||
await flash(f'You have been banned from {gu.handle()}')
|
||||
return
|
||||
|
||||
if not gu.allows_posting(current_user):
|
||||
flash(f'You can\'t post in {gu.handle()}')
|
||||
if not gu.allows_posting(current_user.user):
|
||||
await flash(f'You can\'t post in {gu.handle()}')
|
||||
return
|
||||
|
||||
if p.is_locked:
|
||||
flash(f'You can\'t comment on locked posts')
|
||||
await flash(f'You can\'t comment on locked posts')
|
||||
return
|
||||
|
||||
if 'reply_to' in request.form:
|
||||
reply_to_id = request.form['reply_to']
|
||||
text = request.form['text']
|
||||
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
|
||||
form = await get_request_form()
|
||||
if 'reply_to' in form:
|
||||
reply_to_id = form['reply_to']
|
||||
text = form['text']
|
||||
|
||||
db.session.execute(insert(Comment).values(
|
||||
async with db as session:
|
||||
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
|
||||
|
||||
session.execute(insert(Comment).values(
|
||||
author_id = current_user.id,
|
||||
parent_post_id = p.id,
|
||||
parent_comment_id = reply_to_p,
|
||||
text_content = text
|
||||
))
|
||||
db.session.commit()
|
||||
flash('Comment published')
|
||||
session.commit()
|
||||
await flash('Comment published')
|
||||
return redirect(p.url()), 303
|
||||
abort(501)
|
||||
|
||||
@bp.route('/comments/<b32l:id>')
|
||||
def post_detail(id: int):
|
||||
post: Post | None = db.session.execute(select(Post).where(Post.id == id)).scalar()
|
||||
async def post_detail(id: int):
|
||||
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:
|
||||
return redirect(post.url()), 302
|
||||
else:
|
||||
abort(404)
|
||||
|
||||
def comments_of(p: Post) -> Iterable[Comment]:
|
||||
async def comments_of(p: Post) -> Iterable[Comment]:
|
||||
## 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>/<slug:slug>', methods=['GET', 'POST'])
|
||||
def user_post_detail(username: str, id: int, slug: str = ''):
|
||||
post: Post | None = db.session.execute(select(Post).join(User, User.id == Post.author_id).where(Post.id == id, User.username == username)).scalar()
|
||||
async def user_post_detail(username: str, id: int, slug: str = ''):
|
||||
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)
|
||||
|
||||
if post.slug and slug != post.slug:
|
||||
|
|
@ -98,14 +115,15 @@ def user_post_detail(username: str, id: int, slug: str = ''):
|
|||
if request.method == 'POST':
|
||||
single_post_post_hook(post)
|
||||
|
||||
return render_template('singlepost.html', p=post, 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>/<slug:slug>', methods=['GET', 'POST'])
|
||||
def guild_post_detail(gname, id, slug=''):
|
||||
post: Post | None = db.session.execute(select(Post).join(Guild).where(Post.id == id, Guild.name == gname)).scalar()
|
||||
async def guild_post_detail(gname, id, slug=''):
|
||||
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)
|
||||
|
||||
if post.slug and slug != post.slug:
|
||||
|
|
@ -114,7 +132,7 @@ def guild_post_detail(gname, id, slug=''):
|
|||
if request.method == 'POST':
|
||||
single_post_post_hook(post)
|
||||
|
||||
return render_template('singlepost.html', p=post, comments=comments_of(post), current_guild = post.guild)
|
||||
return await render_template('singlepost.html', p=post, comments=await comments_of(post), current_guild = post.guild)
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -2,19 +2,21 @@
|
|||
|
||||
|
||||
import datetime
|
||||
from flask import Blueprint, abort, flash, redirect, render_template, request
|
||||
from flask_login import current_user, login_required
|
||||
from sqlalchemy import select
|
||||
from quart import Blueprint, abort, flash, redirect, render_template, request
|
||||
from quart_auth import current_user, login_required
|
||||
from sqlalchemy import select, update
|
||||
|
||||
from freak.utils import get_request_form
|
||||
|
||||
from ..models import Post, db
|
||||
|
||||
|
||||
bp = Blueprint('edit', __name__)
|
||||
|
||||
@bp.route('/edit/post/<b32l:id>', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def edit_post(id):
|
||||
p: Post | None = db.session.execute(select(Post).where(Post.id == id, Post.author == current_user)).scalar()
|
||||
async def edit_post(id):
|
||||
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:
|
||||
abort(404)
|
||||
|
|
@ -22,16 +24,17 @@ def edit_post(id):
|
|||
abort(403)
|
||||
|
||||
if request.method == 'POST':
|
||||
text = request.form['text']
|
||||
privacy = int(request.form.get('privacy', '0'))
|
||||
form = await get_request_form()
|
||||
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,
|
||||
privacy = privacy,
|
||||
updated_at = datetime.datetime.now()
|
||||
))
|
||||
db.session.commit()
|
||||
flash('Your changes have been saved')
|
||||
await session.commit()
|
||||
await flash('Your changes have been saved')
|
||||
return redirect(p.url()), 303
|
||||
return render_template('edit.html', p=p)
|
||||
return await render_template('edit.html', p=p)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,65 +1,84 @@
|
|||
|
||||
from flask import Blueprint, render_template, redirect, abort, request
|
||||
from flask_login import current_user
|
||||
from sqlalchemy import select
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
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 ..models import Guild, Post, db
|
||||
from ..algorithms import public_timeline, top_guilds_query, topic_timeline
|
||||
from ..models import Guild, Member, Post, User, db
|
||||
from ..algorithms import public_timeline, topic_timeline
|
||||
|
||||
current_user: UserLoader
|
||||
|
||||
bp = Blueprint('frontpage', __name__)
|
||||
|
||||
@bp.route('/')
|
||||
def homepage():
|
||||
top_communities = [(x[0], x[1], x[2]) for x in
|
||||
db.session.execute(top_guilds_query().limit(10)).fetchall()]
|
||||
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.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
|
||||
# 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)
|
||||
else:
|
||||
# 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/')
|
||||
def explore():
|
||||
async def explore():
|
||||
return render_template('feed.html', feed_type='explore', l=db.paginate(public_timeline()))
|
||||
|
||||
|
||||
@bp.route('/+<name>/')
|
||||
def guild_feed(name):
|
||||
guild: Guild | None = db.session.execute(select(Guild).where(Guild.name == name)).scalar()
|
||||
async def guild_feed(name):
|
||||
async with db as session:
|
||||
guild: Guild | None = (await session.execute(select(Guild).where(Guild.name == name))).scalar()
|
||||
|
||||
if guild is None:
|
||||
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,
|
||||
current_guild=guild)
|
||||
|
||||
@bp.route('/r/<name>/')
|
||||
def guild_feed_r(name):
|
||||
async def guild_feed_r(name):
|
||||
return redirect('/+' + name + '/'), 302
|
||||
|
||||
|
||||
@bp.route("/search", methods=["GET", "POST"])
|
||||
def search():
|
||||
async def search():
|
||||
if request.method == "POST":
|
||||
q = request.form["q"]
|
||||
form = await get_request_form()
|
||||
q = form["q"]
|
||||
if q:
|
||||
results = db.paginate(SearchQuery(q).select(Post, [Post.title]).order_by(Post.created_at.desc()))
|
||||
else:
|
||||
results = None
|
||||
return render_template(
|
||||
return await render_template(
|
||||
"search.html",
|
||||
results=results,
|
||||
q = q
|
||||
)
|
||||
return render_template("search.html")
|
||||
return await render_template("search.html")
|
||||
|
|
|
|||
|
|
@ -1,85 +1,90 @@
|
|||
|
||||
from flask import Blueprint, abort, flash, render_template, request
|
||||
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
|
||||
import datetime
|
||||
|
||||
from ..models import Member, db, User, Guild
|
||||
from freak.utils import get_request_form
|
||||
|
||||
current_user: User
|
||||
from ..models import db, User, Guild
|
||||
|
||||
current_user: UserLoader
|
||||
|
||||
bp = Blueprint('moderation', __name__)
|
||||
|
||||
@bp.route('/+<name>/settings', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def guild_settings(name: str):
|
||||
gu = db.session.execute(select(Guild).where(Guild.name == name)).scalar()
|
||||
async def guild_settings(name: str):
|
||||
form = await get_request_form()
|
||||
|
||||
async with db as session:
|
||||
gu = (await session.execute(select(Guild).where(Guild.name == name))).scalar()
|
||||
|
||||
if not current_user.moderates(gu):
|
||||
abort(403)
|
||||
|
||||
if request.method == 'POST':
|
||||
if current_user.is_administrator and request.form.get('transfer_owner') == current_user.username:
|
||||
if current_user.is_administrator and form.get('transfer_owner') == current_user.username:
|
||||
gu.owner_id = current_user.id
|
||||
db.session.add(gu)
|
||||
db.session.commit()
|
||||
flash(f'Claimed ownership of {gu.handle()}')
|
||||
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
|
||||
display_name = request.form.get('display_name')
|
||||
description = request.form.get('description')
|
||||
exile_name = request.form.get('exile_name')
|
||||
exile_reverse = 'exile_reverse' in request.form
|
||||
restricted = 'restricted' in request.form
|
||||
moderator_name = request.form.get('moderator_name')
|
||||
moderator_consent = 'moderator_consent' in request.form
|
||||
display_name: str = form.get('display_name')
|
||||
description: str = form.get('description')
|
||||
exile_name: str = form.get('exile_name')
|
||||
exile_reverse = 'exile_reverse' in form
|
||||
restricted = 'restricted' in form
|
||||
moderator_name: str = form.get('moderator_name')
|
||||
moderator_consent = 'moderator_consent' in form
|
||||
|
||||
if description and description != gu.description:
|
||||
changes, gu.description = True, description.strip()
|
||||
if display_name and display_name != gu.display_name:
|
||||
changes, gu.display_name = True, display_name.strip()
|
||||
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_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:
|
||||
flash(f'Removed ban on {exile_user.handle()}')
|
||||
await flash(f'Removed ban on {exile_user.handle()}')
|
||||
changes = True
|
||||
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:
|
||||
flash(f'{exile_user.handle()} has been exiled')
|
||||
await flash(f'{exile_user.handle()} has been exiled')
|
||||
changes = True
|
||||
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:
|
||||
changes, gu.is_restricted = True, restricted
|
||||
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:
|
||||
flash(f'User \'{moderator_name}\' not found')
|
||||
await flash(f'User \'{moderator_name}\' not found')
|
||||
elif mu.is_disabled:
|
||||
flash('Suspended users can\'t be moderators')
|
||||
elif mu.has_blocked(current_user):
|
||||
flash(f'User \'{moderator_name}\' not found')
|
||||
await flash('Suspended users can\'t be moderators')
|
||||
elif mu.has_blocked(current_user.user):
|
||||
await flash(f'User \'{moderator_name}\' not found')
|
||||
else:
|
||||
mm = gu.update_member(mu)
|
||||
mm = await gu.update_member(mu)
|
||||
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:
|
||||
flash('Exiled users can\'t be moderators')
|
||||
await flash('Exiled users can\'t be moderators')
|
||||
else:
|
||||
mm.is_moderator = True
|
||||
db.session.add(mm)
|
||||
await session.add(mm)
|
||||
changes = True
|
||||
|
||||
|
||||
if changes:
|
||||
db.session.add(gu)
|
||||
db.session.commit()
|
||||
flash('Changes saved!')
|
||||
session.add(gu)
|
||||
session.commit()
|
||||
await flash('Changes saved!')
|
||||
|
||||
return render_template('guildsettings.html', gu=gu)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,56 +1,64 @@
|
|||
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from flask import Blueprint, render_template, request
|
||||
from flask_login 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 quart import Blueprint, render_template, request
|
||||
from quart_auth import current_user, login_required
|
||||
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__)
|
||||
|
||||
current_user: UserLoader
|
||||
|
||||
def description_text(rlist: list[ReportReason], key: str) -> str:
|
||||
results = [x.description for x in rlist if x.code == key]
|
||||
return results[0] if results else key
|
||||
|
||||
@bp.route('/report/post/<b32l:id>', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def report_post(id: int):
|
||||
p: Post | None = db.session.execute(db.select(Post).where(Post.id == id)).scalar()
|
||||
async def report_post(id: int):
|
||||
async with db as session:
|
||||
p: Post | None = (await session.execute(select(Post).where(Post.id == id))).scalar()
|
||||
if p is None:
|
||||
return render_template('reports/report_404.html', target_type = 1), 404
|
||||
return await render_template('reports/report_404.html', target_type = 1), 404
|
||||
if p.author_id == current_user.id:
|
||||
return render_template('reports/report_self.html', back_to_url=p.url()), 403
|
||||
return await render_template('reports/report_self.html', back_to_url=p.url()), 403
|
||||
if request.method == 'POST':
|
||||
reason = request.args['reason']
|
||||
db.session.execute(db.insert(PostReport).values(
|
||||
await session.execute(insert(PostReport).values(
|
||||
author_id = current_user.id,
|
||||
target_type = REPORT_TARGET_POST,
|
||||
target_id = id,
|
||||
reason_code = REPORT_REASONS[reason]
|
||||
))
|
||||
db.session.commit()
|
||||
return render_template('reports/report_done.html', back_to_url=p.url())
|
||||
return render_template('reports/report_post.html', id = id,
|
||||
session.commit()
|
||||
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)
|
||||
|
||||
@bp.route('/report/comment/<b32l:id>', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def report_comment(id: int):
|
||||
c: Comment | None = db.session.execute(db.select(Comment).where(Comment.id == id)).scalar()
|
||||
async def report_comment(id: int):
|
||||
async with db as session:
|
||||
c: Comment | None = (await session.execute(select(Comment).where(Comment.id == id))).scalar()
|
||||
if c is None:
|
||||
return render_template('reports/report_404.html', target_type = 2), 404
|
||||
return await render_template('reports/report_404.html', target_type = 2), 404
|
||||
if c.author_id == current_user.id:
|
||||
return render_template('reports/report_self.html', back_to_url=c.parent_post.url()), 403
|
||||
return await render_template('reports/report_self.html', back_to_url=c.parent_post.url()), 403
|
||||
if request.method == 'POST':
|
||||
reason = request.args['reason']
|
||||
db.session.execute(db.insert(PostReport).values(
|
||||
session.execute(insert(PostReport).values(
|
||||
author_id = current_user.id,
|
||||
target_type = REPORT_TARGET_COMMENT,
|
||||
target_id = id,
|
||||
reason_code = REPORT_REASONS[reason]
|
||||
))
|
||||
db.session.commit()
|
||||
return render_template('reports/report_done.html',
|
||||
session.commit()
|
||||
return await render_template('reports/report_done.html',
|
||||
back_to_url=c.parent_post.url())
|
||||
return render_template('reports/report_comment.html', id = id,
|
||||
return await render_template('reports/report_comment.html', id = id,
|
||||
report_reasons = post_report_reasons, description_text=description_text)
|
||||
|
||||
|
|
|
|||
|
|
@ -6,19 +6,20 @@ authors = [
|
|||
dynamic = ["version"]
|
||||
dependencies = [
|
||||
"Python-Dotenv>=1.0.0",
|
||||
"Flask",
|
||||
"Flask-RestX",
|
||||
"Quart",
|
||||
"Quart-Schema",
|
||||
"Python-Slugify",
|
||||
"SQLAlchemy>=2.0.0",
|
||||
"Flask-SQLAlchemy",
|
||||
"Flask-WTF",
|
||||
"Flask-Login",
|
||||
# XXX it's Quart-wtFORMS not Quart-wtf see: https://github.com/Quart-Addons/quart-wtf/issues/20
|
||||
"Quart-WTForms>=1.0.3",
|
||||
"Quart-Auth",
|
||||
"Alembic",
|
||||
"Markdown>=3.0.0",
|
||||
"PsycoPG2-binary",
|
||||
"Markdown>=3.0",
|
||||
"PsycoPG>=3.0",
|
||||
"libsass",
|
||||
"setuptools>=78.1.0",
|
||||
"sakuragasaki46-suou>=0.4.0"
|
||||
"Hypercorn",
|
||||
"sakuragasaki46-suou>=0.5.0"
|
||||
]
|
||||
requires-python = ">=3.10"
|
||||
classifiers = [
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ Disallow: /login
|
|||
Disallow: /logout
|
||||
Disallow: /create
|
||||
Disallow: /register
|
||||
Disallow: /createcommunity
|
||||
Disallow: /createguild
|
||||
|
||||
User-Agent: GPTBot
|
||||
Disallow: /
|
||||
Loading…
Add table
Add a link
Reference in a new issue