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