diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0177537..25a8067 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,8 +1,16 @@
# 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](https://nekode.yusur.moe/yusur/vigil).
+- Deprecated the old web routes except for `/report` and `/admin`
+
## 0.4.0
-- Added dependency to [SUOU](https://github.com/sakuragasaki46/suou) library
+- Added dependency to [SUOU](https://github.com/yusurko/suou) library
- Users can now block each other
+ Blocking a user prevents them from seeing your comments, posts (standalone or in feed) and profile
- Added user strikes: a strike logs the content of a removed message for future use
diff --git a/README.md b/README.md
index 1308d66..8d1a4e9 100644
--- a/README.md
+++ b/README.md
@@ -11,7 +11,7 @@
* Unix-like OS (Docker container, Linux or MacOS are all good).
* **Python** >=3.10. Recommended to use a virtualenv (unless in Docker lol).
* **PostgreSQL** at least 16.
- * **Redis**/Valkey (as of 0.4.0 unused in codebase -_-).
+ * **Redis**/Valkey (as of 0.5.0 unused in codebase -_-).
* **Docker** and **Docker Compose**.
* A server machine with a public IP address and shell access (mandatory for production, optional for development/staging).
* First time? I recommend a VPS. The cheapest one starts at €5/month, half a Spotify subscription.
@@ -24,7 +24,7 @@
* Will to not give up.
* Clone this repository.
* Fill in `.env` with the necessary information.
- * `DOMAIN_NAME` (see above)
+ * `SERVER_NAME` (see above)
* `APP_NAME`
* `DATABASE_URL` (hint: `postgresql://username:password@localhost/dbname`)
* `SECRET_KEY` (you can generate one with the command `cat /dev/random | tr -dc A-Za-z0-9_. | head -c 56`)
diff --git a/docker-run.sh b/docker-run.sh
index 331cbe1..9dbe0e7 100644
--- a/docker-run.sh
+++ b/docker-run.sh
@@ -6,7 +6,7 @@ start-app() {
cp -rv /opt/live-app/{freak,pyproject.toml,docker-run.sh} ./
cp -v /opt/live-app/.env.prod .env
pip install -e .
- flask --app freak run --host=0.0.0.0
+ hypercorn freak:app -b 0.0.0.0:5000
}
[[ "$1" = "" ]] && start-app
diff --git a/freak/__init__.py b/freak/__init__.py
index 607b400..fcac5d5 100644
--- a/freak/__init__.py
+++ b/freak/__init__.py
@@ -1,30 +1,32 @@
+import logging
import re
from sqlite3 import ProgrammingError
+import sys
from typing import Any
import warnings
-from flask import (
- Flask, g, redirect, render_template,
+from quart import (
+ Quart, flash, g, jsonify, redirect, render_template,
request, send_from_directory, url_for
)
import os
import dotenv
-from flask_login import LoginManager
-from flask_wtf.csrf import CSRFProtect
-from sqlalchemy import select
-from sqlalchemy.exc import SQLAlchemyError
-from suou import Snowflake, ssv_list
+from quart_auth import AuthUser, QuartAuth, Action as QA_Action, current_user
+from quart_wtf import CSRFProtect
+from sqlalchemy import inspect, select
+from suou import Snowflake, ssv_list, yesno
from werkzeug.routing import BaseConverter
-from sassutils.wsgi import SassMiddleware
-from werkzeug.middleware.proxy_fix import ProxyFix
+from suou.sass import SassAsyncMiddleware
+from suou.quart import negotiate
+from hypercorn.middleware import ProxyFixMiddleware
from suou.configparse import ConfigOptions, ConfigValue
+from suou import twocolon_list, WantsContentType
from .colors import color_themes, theme_classes
-from .utils import twocolon_list
-__version__ = '0.4.0'
+__version__ = '0.5.0-dev50'
APP_BASE_DIR = os.path.dirname(os.path.dirname(__file__))
@@ -35,31 +37,46 @@ class AppConfig(ConfigOptions):
secret_key = ConfigValue(required=True)
database_url = ConfigValue(required=True)
app_name = ConfigValue()
- domain_name = ConfigValue()
+ server_name = ConfigValue()
+ force_server_name = ConfigValue(cast=yesno, default=True)
private_assets = ConfigValue(cast=ssv_list)
- jquery_url = ConfigValue(default='https://ajax.googleapis.com/ajax/libs/jquery/3.7.1/jquery.min.js')
- app_is_behind_proxy = ConfigValue(cast=bool, default=False)
+ app_is_behind_proxy = ConfigValue(cast=int, default=0)
impressum = ConfigValue(cast=twocolon_list, default='')
create_guild_threshold = ConfigValue(cast=int, default=15, prefix='freak_')
+ # v-- deprecated --v
+ jquery_url = ConfigValue(default='https://ajax.googleapis.com/ajax/libs/jquery/3.7.1/jquery.min.js')
+ # ^----------------^
app_config = AppConfig()
-app = Flask(__name__)
+logging.basicConfig(level=logging.WARNING)
+
+logger = logging.getLogger(__name__)
+
+app = Quart(__name__)
app.secret_key = app_config.secret_key
app.config['SQLALCHEMY_DATABASE_URI'] = app_config.database_url
app.config['JSONIFY_PRETTYPRINT_REGULAR'] = False
+app.config['QUART_AUTH_DURATION'] = 365 * 24 * 60 * 60
-from .models import db, User, Post
+if app_config.server_name and app_config.force_server_name:
+ app.config['SERVER_NAME'] = app_config.server_name
+
+
+## DO NOT ADD LOCAL IMPORTS BEFORE THIS LINE
+
+from .accounts import UserLoader
+from .models import Guild, db, User, Post
# SASS
-app.wsgi_app = SassMiddleware(app.wsgi_app, dict(
+app.asgi_app = SassAsyncMiddleware(app.asgi_app, dict(
freak=('static/sass', 'static/css', '/static/css', True)
))
# proxy fix
if app_config.app_is_behind_proxy:
- app.wsgi_app = ProxyFix(
- app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1
+ app.asgi_app = ProxyFixMiddleware(
+ app.asgi_app, trusted_hops=app_config.app_is_behind_proxy, mode='legacy'
)
class SlugConverter(BaseConverter):
@@ -75,100 +92,177 @@ class B32lConverter(BaseConverter):
app.url_map.converters['slug'] = SlugConverter
app.url_map.converters['b32l'] = B32lConverter
-db.init_app(app)
+db.bind(app_config.database_url)
csrf = CSRFProtect(app)
-login_manager = LoginManager(app)
-login_manager.login_view = 'accounts.login'
+
+
+
+# TODO configure quart_auth
+login_manager = QuartAuth(app, user_class= UserLoader)
from . import filters
PRIVATE_ASSETS = os.getenv('PRIVATE_ASSETS', '').split()
+post_count_cache = 0
+user_count_cache = 0
+
@app.context_processor
-def _inject_variables():
+async def _inject_variables():
+ global post_count_cache, user_count_cache
+ try:
+ post_count = await Post.count()
+ user_count = await User.active_count()
+ except Exception as e:
+ logger.error(f'cannot compute post_count: {e}')
+ post_count = post_count_cache
+ user_count = user_count_cache
+ else:
+ post_count_cache = post_count
+ user_count_cache = user_count
+
return {
'app_name': app_config.app_name,
'app_version': __version__,
- 'domain_name': app_config.domain_name,
+ 'server_name': app_config.server_name,
'url_for_css': (lambda name, **kwargs: url_for('static', filename=f'css/{name}.css', **kwargs)),
'private_scripts': [x for x in app_config.private_assets if x.endswith('.js')],
'private_styles': [x for x in PRIVATE_ASSETS if x.endswith('.css')],
'jquery_url': app_config.jquery_url,
- 'post_count': Post.count(),
- 'user_count': User.active_count(),
+ 'post_count': post_count,
+ 'user_count': user_count,
'colors': color_themes,
'theme_classes': theme_classes,
'impressum': '\n'.join(app_config.impressum).replace('_', ' ')
}
-@login_manager.user_loader
-def _inject_user(userid):
+@app.before_request
+async def _load_user():
try:
- u = db.session.execute(select(User).where(User.id == userid)).scalar()
- if u is None or u.is_disabled:
- return None
- return u
- except SQLAlchemyError as e:
- warnings.warn(f'cannot retrieve user {userid} from db (exception: {e})', RuntimeWarning)
+ await current_user._load()
+ except RuntimeError as e:
+ logger.error(f'{e}')
g.no_user = True
- return None
+
+
+@app.after_request
+async def _unload_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:
if not isinstance(u, str):
return u
return re.sub(r':[^@:/ ]+@', ':***@', u)
+async def error_handler_for(status: int, message: str, template: str):
+ match negotiate():
+ case WantsContentType.JSON:
+ return jsonify({'error': f'{message}', 'status': status}), status
+ case WantsContentType.HTML:
+ if request.path.startswith('/admin'):
+ return await render_template('admin/' + template, message=f'{message}'), status
+ return await render_template(template, message=f'{message}'), status
+ case WantsContentType.PLAIN:
+ return f'{message} (HTTP {status})', status, {'content-type': 'text/plain; charset=UTF-8'}
+
@app.errorhandler(ProgrammingError)
-def error_db(body):
+async def error_db(body):
g.no_user = True
- warnings.warn(f'No database access! (url is {redact_url_password(app.config['SQLALCHEMY_DATABASE_URI'])!r})', RuntimeWarning)
- return render_template('500.html'), 500
+ logger.error(f'No database access! (url is {redact_url_password(app.config['SQLALCHEMY_DATABASE_URI'])!r})', RuntimeWarning)
+ return await error_handler_for(500, body, '500.html')
@app.errorhandler(400)
-def error_400(body):
- return render_template('400.html'), 400
+async def error_400(body):
+ return await error_handler_for(400, body, '400.html')
+
+@app.errorhandler(401)
+async def error_401(body):
+ match negotiate():
+ case WantsContentType.HTML:
+ return redirect(url_for('accounts.login', next=request.path))
+ case _:
+ return await error_handler_for(401, 'Please log in.', 'login.html')
+
@app.errorhandler(403)
-def error_403(body):
- return render_template('403.html'), 403
+async def error_403(body):
+ return await error_handler_for(403, body, '403.html')
-from .search import find_guild_or_user
+async def find_guild_or_user(name: str) -> str | None:
+ """
+ Used in 404 error handler.
+
+ Returns an URL to redirect or None for no redirect.
+ """
+
+ if hasattr(g, 'no_user'):
+ return None
+
+ # do not execute for non-browsers_
+ if 'Mozilla/' not in request.user_agent.string:
+ return None
+
+ async with db as session:
+ gu = (await session.execute(select(Guild).where(Guild.name == name))).scalar()
+ user = (await session.execute(select(User).where(User.username == name))).scalar()
+
+ if gu is not None:
+ await flash(f'There is nothing at /{name}. Luckily, a guild with name {gu.handle()} happens to exist. Next time, remember to add + before!')
+ return gu.url()
+
+ if user is not None:
+ await flash(f'There is nothing at /{name}. Luckily, a user named {user.handle()} happens to exist. Next time, remember to add @ before!')
+ return user.url()
+
+ return None
@app.errorhandler(404)
-def error_404(body):
+async def error_404(body):
try:
if mo := re.match(r'/([a-z0-9_-]+)/?', request.path):
- alternative = find_guild_or_user(mo.group(1))
+ alternative = await find_guild_or_user(mo.group(1))
if alternative is not None:
return redirect(alternative), 302
except Exception as e:
- warnings.warn(f'Exception in find_guild_or_user: {e}')
+ logger.error(f'Exception in find_guild_or_user: {e}')
pass
- return render_template('404.html'), 404
+ if app_config.server_name not in (None, request.host):
+ logger.warning(f'request host {request.host!r} is different from configured server name {app_config.server_name!r}')
+ if request.referrer:
+ logger.warning(f'(referrer is {request.referrer!r}')
+ if request.host == request.referrer:
+ return {"error": "Loop detected"}, 508
+ return redirect('//' + app_config.server_name + request.full_path), 307
+ return await error_handler_for(404, 'Not found', '404.html')
@app.errorhandler(405)
-def error_405(body):
- return render_template('405.html'), 405
+async def error_405(body):
+ return await error_handler_for(405, body, '405.html')
@app.errorhandler(451)
-def error_451(body):
- return render_template('451.html'), 451
+async def error_451(body):
+ return await error_handler_for(451, body, '451.html')
@app.errorhandler(500)
-def error_500(body):
+async def error_500(body):
g.no_user = True
- return render_template('500.html'), 500
+ return await error_handler_for(500, body, '500.html')
@app.route('/favicon.ico')
-def favicon_ico():
- return send_from_directory(APP_BASE_DIR, 'favicon.ico')
+async def favicon_ico():
+ return await send_from_directory(APP_BASE_DIR, 'favicon.ico')
@app.route('/robots.txt')
-def robots_txt():
- return send_from_directory(APP_BASE_DIR, 'robots.txt')
+async def robots_txt():
+ return await send_from_directory(APP_BASE_DIR, 'robots.txt')
from .website import blueprints
@@ -178,8 +272,8 @@ for bp in blueprints:
from .ajax import bp
app.register_blueprint(bp)
-from .rest import rest_bp
-app.register_blueprint(rest_bp)
+from .rest import bp
+app.register_blueprint(bp)
diff --git a/freak/__main__.py b/freak/__main__.py
index df77c43..0f15538 100644
--- a/freak/__main__.py
+++ b/freak/__main__.py
@@ -1,4 +1,6 @@
+import asyncio
from .cli import main
-main()
\ No newline at end of file
+asyncio.run(main())
+
diff --git a/freak/accounts.py b/freak/accounts.py
new file mode 100644
index 0000000..db34516
--- /dev/null
+++ b/freak/accounts.py
@@ -0,0 +1,89 @@
+
+
+import logging
+import enum
+
+from sqlalchemy import select
+from sqlalchemy.orm import selectinload
+from suou.sqlalchemy.asyncio import AsyncSession
+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: AsyncSession | None = 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
+
+ @property
+ def session(self):
+ return self._auth_sess
+
+ 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
+ color_theme: int
diff --git a/freak/ajax.py b/freak/ajax.py
index 00107ed..19e964c 100644
--- a/freak/ajax.py
+++ b/freak/ajax.py
@@ -1,29 +1,35 @@
'''
-AJAX hooks for the website.
+AJAX hooks for the OLD frontend.
-2025 DEPRECATED in favor of /v1/ (REST)
+DEPRECATED in 0.5 in favor of /v1/ (REST)
'''
+from __future__ import annotations
+
import re
-from flask import Blueprint, abort, flash, redirect, request
+from quart import Blueprint, abort, flash, redirect, request
from sqlalchemy import delete, insert, select
-from .models import Guild, Member, UserBlock, db, User, Post, PostUpvote, username_is_legal
-from flask_login import current_user, login_required
-current_user: User
+from freak import UserLoader
+from freak.utils import get_request_form
+from .models import Guild, Member, UserBlock, db, User, Post, PostUpvote, username_is_legal
+from quart_auth import current_user, login_required
+
+current_user: UserLoader
bp = Blueprint('ajax', __name__)
@bp.route('/username_availability/')
@bp.route('/ajax/username_availability/')
-def username_availability(username: str):
+async def username_availability(username: str):
is_valid = username_is_legal(username)
if is_valid:
- user = db.session.execute(select(User).where(User.username == username)).scalar()
+ async with db as session:
+ user = (await session.execute(select(User).where(User.username == username))).scalar()
- is_available = user is None or user == current_user
+ is_available = user is None or user == current_user.user
else:
is_available = False
@@ -34,13 +40,14 @@ def username_availability(username: str):
}
@bp.route('/guild_name_availability/')
-def guild_name_availability(name: str):
+async def guild_name_availability(name: str):
is_valid = username_is_legal(name)
if is_valid:
- gd = db.session.execute(select(Guild).where(Guild.name == name)).scalar()
+ async with db as session:
+ gd = (await session.execute(select(Guild).where(Guild.name == name))).scalar()
- is_available = gd is None
+ is_available = gd is None
else:
is_available = False
@@ -52,101 +59,112 @@ def guild_name_availability(name: str):
@bp.route('/comments//upvote', methods=['POST'])
@login_required
-def post_upvote(id):
- o = request.form['o']
- p: Post | None = db.session.execute(select(Post).where(Post.id == id)).scalar()
+async def post_upvote(id):
+ form = await get_request_form()
+ o = form['o']
+ async with db as session:
+ p: Post | None = (await session.execute(select(Post).where(Post.id == id))).scalar()
- if p is None:
- return { 'status': 'fail', 'message': 'Post not found' }, 404
-
- if o == '1':
- db.session.execute(delete(PostUpvote).where(PostUpvote.c.post_id == p.id, PostUpvote.c.voter_id == current_user.id, PostUpvote.c.is_downvote == True))
- db.session.execute(insert(PostUpvote).values(post_id = p.id, voter_id = current_user.id, is_downvote = False))
- elif o == '0':
- db.session.execute(delete(PostUpvote).where(PostUpvote.c.post_id == p.id, PostUpvote.c.voter_id == current_user.id))
- elif o == '-1':
- db.session.execute(delete(PostUpvote).where(PostUpvote.c.post_id == p.id, PostUpvote.c.voter_id == current_user.id, PostUpvote.c.is_downvote == False))
- db.session.execute(insert(PostUpvote).values(post_id = p.id, voter_id = current_user.id, is_downvote = True))
- else:
- return { 'status': 'fail', 'message': 'Invalid score' }, 400
+ if p is None:
+ return { 'status': 'fail', 'message': 'Post not found' }, 404
+
+ cur_score = await p.upvoted_by(current_user.user)
- db.session.commit()
- return { 'status': 'ok', 'count': p.upvotes() }
+ match (o, cur_score):
+ case ('1', 0) | ('1', -1):
+ await session.execute(delete(PostUpvote).where(PostUpvote.c.post_id == p.id, PostUpvote.c.voter_id == current_user.id, PostUpvote.c.is_downvote == True))
+ await session.execute(insert(PostUpvote).values(post_id = p.id, voter_id = current_user.id, is_downvote = False))
+ case ('0', _):
+ await session.execute(delete(PostUpvote).where(PostUpvote.c.post_id == p.id, PostUpvote.c.voter_id == current_user.id))
+ case ('-1', 1) | ('-1', 0):
+ await session.execute(delete(PostUpvote).where(PostUpvote.c.post_id == p.id, PostUpvote.c.voter_id == current_user.id, PostUpvote.c.is_downvote == False))
+ await session.execute(insert(PostUpvote).values(post_id = p.id, voter_id = current_user.id, is_downvote = True))
+ case ('1', 1) | ('-1', -1):
+ pass
+ case _:
+ await session.rollback()
+ return { 'status': 'fail', 'message': 'Invalid score' }, 400
+
+ await session.commit()
+ return { 'status': 'ok', 'count': await p.upvotes() }
@bp.route('/@/block', methods=['POST'])
@login_required
-def block_user(username):
- u = db.session.execute(select(User).where(User.username == username)).scalar()
-
- if u is None:
- abort(404)
-
- is_block = 'reverse' not in request.form
- is_unblock = request.form.get('reverse') == '1'
+async def block_user(username):
+ form = await get_request_form()
- if is_block:
- if current_user.has_blocked(u):
- flash(f'{u.handle()} is already blocked')
- else:
- db.session.execute(insert(UserBlock).values(
- actor_id = current_user.id,
- target_id = u.id
- ))
- db.session.commit()
- flash(f'{u.handle()} is now blocked')
-
- if is_unblock:
- if not current_user.has_blocked(u):
- flash('You didn\'t block this user')
- else:
- db.session.execute(delete(UserBlock).where(
- UserBlock.c.actor_id == current_user.id,
- UserBlock.c.target_id == u.id
- ))
- db.session.commit()
- flash(f'Removed block on {u.handle()}')
+ async with db as session:
+ u = (await session.execute(select(User).where(User.username == username))).scalar()
+ if u is None:
+ abort(404)
+
+ is_block = 'reverse' not in form
+ is_unblock = form.get('reverse') == '1'
+
+ if is_block:
+ if current_user.has_blocked(u):
+ await flash(f'{u.handle()} is already blocked')
+ else:
+ await session.execute(insert(UserBlock).values(
+ actor_id = current_user.id,
+ target_id = u.id
+ ))
+ await flash(f'{u.handle()} is now blocked')
+
+ if is_unblock:
+ if not current_user.has_blocked(u):
+ await flash('You didn\'t block this user')
+ else:
+ await session.execute(delete(UserBlock).where(
+ UserBlock.c.actor_id == current_user.id,
+ UserBlock.c.target_id == u.id
+ ))
+ await flash(f'Removed block on {u.handle()}')
+
return redirect(request.args.get('next', u.url())), 303
@bp.route('/+/subscribe', methods=['POST'])
@login_required
-def subscribe_guild(name):
- gu = db.session.execute(select(Guild).where(Guild.name == name)).scalar()
+async def subscribe_guild(name):
+ form = await get_request_form()
- if gu is None:
- abort(404)
-
- is_join = 'reverse' not in request.form
- is_leave = request.form.get('reverse') == '1'
+ async with db as session:
+ gu = (await session.execute(select(Guild).where(Guild.name == name))).scalar()
- membership = db.session.execute(select(Member).where(Member.guild == gu, Member.user_id == current_user.id)).scalar()
+ if gu is None:
+ abort(404)
+
+ is_join = 'reverse' not in form
+ is_leave = form.get('reverse') == '1'
- if is_join:
- 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()}")
+ membership = (await session.execute(select(Member).where(Member.guild == gu, Member.user_id == current_user.id))).scalar()
- if is_leave:
- if membership is None:
- return redirect(gu.url()), 303
- elif membership.is_subscribed == True:
- membership.is_subscribed = False
- db.session.add(membership)
- else:
- return redirect(gu.url()), 303
+ if is_join:
+ if membership is None:
+ membership = (await session.execute(insert(Member).values(
+ guild_id = gu.id,
+ user_id = current_user.id,
+ is_subscribed = True
+ ).returning(Member))).scalar()
+ elif membership.is_subscribed == False:
+ membership.is_subscribed = True
+ await session.add(membership)
+ else:
+ return redirect(gu.url()), 303
+ await flash(f"You are now subscribed to {gu.handle()}")
- db.session.commit()
- flash(f"Unsubscribed from {gu.handle()}.")
+ if is_leave:
+ if membership is None:
+ return redirect(gu.url()), 303
+ elif membership.is_subscribed == True:
+ membership.is_subscribed = False
+ await session.add(membership)
+ else:
+ return redirect(gu.url()), 303
+
+ await session.commit()
+ await flash(f"Unsubscribed from {gu.handle()}.")
return redirect(gu.url()), 303
diff --git a/freak/algorithms.py b/freak/algorithms.py
index 04d8258..cba3c0e 100644
--- a/freak/algorithms.py
+++ b/freak/algorithms.py
@@ -1,16 +1,18 @@
-from flask_login import current_user
+from quart_auth import current_user
from sqlalchemy import and_, distinct, func, select
-from .models import Comment, Member, db, Post, Guild, User
+from suou import not_implemented
+
+from .models import Comment, Member, Post, Guild, User
+
-current_user: User
def cuser() -> User:
- return current_user if current_user.is_authenticated else None
+ return current_user.user if current_user else None
def cuser_id() -> int:
- return current_user.id if current_user.is_authenticated else None
+ return current_user.id if current_user else None
def public_timeline():
return select(Post).join(User, User.id == Post.author_id).where(
@@ -18,15 +20,20 @@ def public_timeline():
).order_by(Post.created_at.desc())
def topic_timeline(gname):
- return select(Post).join(Guild).join(User, User.id == Post.author_id).where(
+ return select(Post).join(Guild, Guild.id == Post.topic_id).join(User, User.id == Post.author_id).where(
Post.privacy == 0, Guild.name == gname, User.not_suspended(), Post.not_removed(), User.has_not_blocked(Post.author_id, cuser_id())
).order_by(Post.created_at.desc())
-def user_timeline(user_id):
+def user_timeline(user: User):
return select(Post).join(User, User.id == Post.author_id).where(
- Post.visible_by(cuser_id()), User.id == user_id, User.not_suspended(), Post.not_removed(), User.has_not_blocked(Post.author_id, cuser_id())
+ Post.visible_by(cuser_id()), Post.author_id == user.id, User.not_suspended(), Post.not_removed(), User.has_not_blocked(Post.author_id, cuser_id())
).order_by(Post.created_at.desc())
+def new_comments(p: Post):
+ return select(Comment).join(Post, Post.id == Comment.parent_post_id).join(User, User.id == Comment.author_id
+ ).where(Comment.parent_post_id == p.id, Comment.parent_comment_id == None, Comment.not_removed(), User.has_not_blocked(Comment.author_id, cuser_id())
+ ).order_by(Comment.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')
@@ -36,6 +43,13 @@ def top_guilds_query():
.group_by(Guild).having(q_post_count > 5).order_by(q_post_count.desc(), q_sub_count.desc())
return qr
-def new_comments(p: Post):
- return select(Comment).where(Comment.parent_post_id == p.id, Comment.parent_comment_id == None,
- Comment.not_removed(), User.has_not_blocked(Comment.author_id, cuser_id())).order_by(Comment.created_at.desc())
+
+@not_implemented()
+class Algorithms:
+ """
+ Return SQL queries for algorithms.
+ """
+ def __init__(self, me: User | None):
+ self.me = me
+
+
\ No newline at end of file
diff --git a/freak/cli.py b/freak/cli.py
index 34a1959..63b508a 100644
--- a/freak/cli.py
+++ b/freak/cli.py
@@ -6,7 +6,7 @@ import subprocess
from sqlalchemy import create_engine, select
from sqlalchemy.orm import Session
-from . import __version__ as version, app
+from . import __version__ as version, app_config
from .models import User, db
def make_parser():
@@ -16,7 +16,7 @@ def make_parser():
parser.add_argument('--flush', '-H', action='store_true', help='recompute karma for all users')
return parser
-def main():
+async def main():
args = make_parser().parse_args()
engine = create_engine(os.getenv('DATABASE_URL'))
@@ -26,18 +26,19 @@ def main():
print(f'Schema upgrade failed (code: {ret_code})')
exit(ret_code)
# if the alembic/versions folder is empty
- db.metadata.create_all(engine)
+ await db.create_all(engine)
print('Schema upgraded!')
if args.flush:
cnt = 0
- with app.app_context():
- for u in db.session.execute(select(User)).scalars():
+ async with db as session:
+
+ for u in (await session.execute(select(User))).scalars():
u.recompute_karma()
cnt += 1
- db.session.add(u)
- db.session.commit()
+ session.add(u)
+ session.commit()
print(f'Recomputed karma of {cnt} users')
- print(f'Visit ')
+ print(f'Visit ')
diff --git a/freak/colors.py b/freak/colors.py
index 2391f87..39171eb 100644
--- a/freak/colors.py
+++ b/freak/colors.py
@@ -21,7 +21,7 @@ color_themes = [
ColorTheme(10, 'Defoko'),
ColorTheme(11, 'Kaito'),
ColorTheme(12, 'Meiko'),
- ColorTheme(13, 'Leek'),
+ ColorTheme(13, 'WhatsApp'),
ColorTheme(14, 'Teto'),
ColorTheme(15, 'Ruby')
]
diff --git a/freak/dei.py b/freak/dei.py
deleted file mode 100644
index 8ddebb4..0000000
--- a/freak/dei.py
+++ /dev/null
@@ -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)
diff --git a/freak/models.py b/freak/models.py
index caf4e72..d34d567 100644
--- a/freak/models.py
+++ b/freak/models.py
@@ -2,28 +2,36 @@
from __future__ import annotations
+import asyncio
from collections import namedtuple
import datetime
from functools import partial
from operator import or_
import re
from threading import Lock
-from sqlalchemy import Column, Integer, String, ForeignKey, UniqueConstraint, insert, text, \
+from typing import Any, Callable
+from quart_auth import current_user
+from sqlalchemy import Column, Integer, String, ForeignKey, UniqueConstraint, and_, insert, text, \
CheckConstraint, Date, DateTime, Boolean, func, BigInteger, \
SmallInteger, select, update, Table
from sqlalchemy.orm import Relationship, relationship
-from flask_sqlalchemy import SQLAlchemy
-from flask_login import AnonymousUserMixin
-from suou import SiqType, Snowflake, Wanted, deprecated, not_implemented
+from suou.sqlalchemy_async import SQLAlchemy
+from suou import SiqType, Snowflake, Wanted, deprecated, makelist, not_implemented, want_isodate
from suou.sqlalchemy import create_session, declarative_base, id_column, parent_children, snowflake_column
from werkzeug.security import check_password_hash
-from freak import app_config
-from .utils import age_and_days, get_remote_addr, timed_cache
+from . import app_config
+from .utils import get_remote_addr
+from suou import timed_cache, age_and_days
+
+import logging
+
+logger = logging.getLogger(__name__)
## Constants and enums
+## NOT IN USE: User has .banned_at and .is_disabled_by_user
USER_ACTIVE = 0
USER_INACTIVE = 1
USER_BANNED = 2
@@ -71,16 +79,16 @@ ILLEGAL_USERNAMES = tuple((
'me everyone here room all any server app dev devel develop nil none '
'founder owner admin administrator mod modteam moderator sysop some '
## fictitious users and automations
- 'nobody deleted suspended default bot developer undefined null '
- 'ai automod automoderator assistant privacy anonymous removed assistance '
+ 'nobody somebody deleted suspended default bot developer undefined null '
+ 'ai automod clanker automoderator assistant privacy anonymous removed assistance '
## law enforcement corps and slurs because yes
'pedo rape rapist nigger retard ncmec police cops 911 childsafety '
'report dmca login logout security order66 gestapo ss hitler heilhitler kgb '
'pedophile lolicon giphy tenor csam cp pedobear lolita lolice thanos '
- 'loli kkk pnf adl cop tranny google trustandsafety safety ice fbi nsa it '
+ 'loli lolicon kkk pnf adl cop tranny google trustandsafety safety ice fbi nsa it '
## VVVVIP
'potus realdonaldtrump elonmusk teddysphotos mrbeast jkrowling pewdiepie '
- 'elizabethii king queen pontifex hogwarts lumos alohomora isis daesh '
+ 'elizabethii elizabeth2 king queen pontifex hogwarts lumos alohomora isis daesh retards '
).split())
def username_is_legal(username: str) -> bool:
@@ -94,21 +102,26 @@ def username_is_legal(username: str) -> bool:
return False
return True
+def want_User(o: User | Any | None, *, prefix: str = '', var_name: str = '') -> User | None:
+ if isinstance(o, User):
+ return o
+ if o is None:
+ return None
+ logger.warning(f'{prefix}: {repr(var_name) + " has " if var_name else ""}invalid type {o.__class__.__name__}, expected User')
+ return None
+
## END constants and enums
-Base = declarative_base(app_config.domain_name, app_config.secret_key,
+Base = declarative_base(app_config.server_name, app_config.secret_key,
snowflake_epoch=1577833200)
db = SQLAlchemy(model_class=Base)
CSI = create_session_interactively = partial(create_session, app_config.database_url)
-# the BaseModel() class will be removed in 0.5
-from .iding import new_id
-@deprecated('id_column() and explicit id column are better. Will be removed in 0.5')
-class BaseModel(Base):
- __abstract__ = True
- id = Column(BigInteger, primary_key=True, default=new_id)
+## .accounts requires db
+#current_user: UserLoader
+
## Many-to-many relationship keys for some reasons have to go
## BEFORE other table definitions.
@@ -151,6 +164,7 @@ class User(Base):
karma = Column(BigInteger, server_default=text('0'), nullable=False)
legacy_id = Column(BigInteger, nullable=True)
+ # pronouns must be set via suou.dei.Pronoun.from_short()
pronouns = Column(Integer, server_default=text('0'), nullable=False)
biography = Column(String(1024), nullable=True)
color_theme = Column(SmallInteger, nullable=False, server_default=text('0'))
@@ -171,8 +185,8 @@ class User(Base):
## SQLAlchemy fail initialization of models — bricking the app.
## Posts are queried manually anyway
#posts = relationship("Post", primaryjoin=lambda: #back_populates='author', pr)
- upvoted_posts = relationship("Post", secondary=PostUpvote, back_populates='upvoters')
- #comments = relationship("Comment", back_populates='author')
+ upvoted_posts = relationship("Post", secondary=PostUpvote, back_populates='upvoters', lazy='selectin')
+ #comments = relationship("Comment", back_populates='author', lazy='selectin')
@property
def is_disabled(self):
@@ -189,13 +203,16 @@ class User(Base):
return not self.is_disabled
@property
+ @deprecated('shadowed by UserLoader.is_authenticated(), and always true')
def is_authenticated(self):
return True
@property
+ @deprecated('no more in use since switch to Quart')
def is_anonymous(self):
return False
+ @deprecated('this representation uses decimal, URLs use b32l')
def get_id(self):
return str(self.id)
@@ -206,26 +223,32 @@ class User(Base):
def age(self):
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
"""
## XXX change func name?
- return dict(
+ gg = dict(
id = Snowflake(self.id).to_b32l(),
username = self.username,
display_name = self.display_name,
- age = self.age()
- ## TODO add badges?
- )
+ age = self.age(),
+ badges = self.badges(),
- def reward(self, points=1):
+ )
+ 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
"""
with Lock():
- db.session.execute(update(User).where(User.id == self.id).values(karma = self.karma + points))
- db.session.commit()
+ async with db as session:
+ await session.execute(update(User).where(User.id == self.id).values(karma = self.karma + points))
+ await session.commit()
def can_create_guild(self):
## TODO make guild creation requirements fully configurable
@@ -240,10 +263,12 @@ class User(Base):
return check_password_hash(self.passhash, password)
@classmethod
- @timed_cache(1800)
- def active_count(cls) -> int:
+ @timed_cache(1800, async_=True)
+ async def active_count(cls) -> int:
active_th = datetime.datetime.now() - datetime.timedelta(days=30)
- return db.session.execute(select(func.count(User.id)).select_from(cls).join(Post, Post.author_id == User.id).where(Post.created_at >= active_th).group_by(User.id)).scalar()
+ async with db as session:
+ count = (await session.execute(select(func.count(User.id)).select_from(cls).join(Post, Post.author_id == User.id).where(Post.created_at >= active_th).group_by(User.id))).scalar()
+ return count
def __repr__(self):
return f'<{self.__class__.__name__} id:{self.id!r} username:{self.username!r}>'
@@ -252,10 +277,25 @@ class User(Base):
def not_suspended(cls):
return or_(User.banned_at == None, User.banned_until <= datetime.datetime.now())
- def has_blocked(self, other: User | None) -> bool:
- if other is None or not other.is_authenticated:
+ async def has_blocked(self, other: User | None) -> bool:
+ if not want_User(other, var_name='other', prefix='User.has_blocked()'):
return False
- return bool(db.session.execute(select(UserBlock).where(UserBlock.c.actor_id == self.id, UserBlock.c.target_id == other.id)).scalar())
+ async with db as session:
+ block_exists = (await session.execute(select(UserBlock).where(UserBlock.c.actor_id == self.id, UserBlock.c.target_id == other.id))).scalar()
+ return bool(block_exists)
+
+ async def is_blocked_by(self, other: User | None) -> bool:
+ if not want_User(other, var_name='other', prefix='User.is_blocked_by()'):
+ return False
+ async with db as session:
+ block_exists = (await session.execute(select(UserBlock).where(UserBlock.c.actor_id == other.id, UserBlock.c.target_id == self.id))).scalar()
+ return bool(block_exists)
+
+ def has_blocked_q(self, other_id: int):
+ return select(UserBlock).where(UserBlock.c.actor_id == self.id, UserBlock.c.target_id == other_id).exists()
+
+ def blocked_by_q(self, other_id: int):
+ return select(UserBlock).where(UserBlock.c.actor_id == other_id, UserBlock.c.target_id == self.id).exists()
@not_implemented()
def end_friendship(self, other: User):
@@ -268,10 +308,10 @@ class User(Base):
def has_subscriber(self, other: User) -> bool:
# TODO implement in 0.5
- return False #bool(db.session.execute(select(Friendship).where(...)).scalar())
+ return False #bool(session.execute(select(Friendship).where(...)).scalar())
@classmethod
- def has_not_blocked(cls, actor, target):
+ def has_not_blocked(cls, actor: int, target: int):
"""
Filter out a content if the author has blocked current user. Returns a query.
@@ -285,33 +325,64 @@ class User(Base):
qq= ~select(UserBlock).where(UserBlock.c.actor_id == actor_id, UserBlock.c.target_id == target_id).exists()
return qq
- def recompute_karma(self):
- c = 0
- c += db.session.execute(select(func.count('*')).select_from(Post).where(Post.author == self)).scalar()
- c += db.session.execute(select(func.count('*')).select_from(PostUpvote).join(Post).where(Post.author == self, PostUpvote.c.is_downvote == False)).scalar()
- c -= db.session.execute(select(func.count('*')).select_from(PostUpvote).join(Post).where(Post.author == self, PostUpvote.c.is_downvote == True)).scalar()
+ async def recompute_karma(self):
+ """
+ Recompute karma as of 0.4.0 karma handling
+ """
+ async with db as session:
+ c = 0
+ c += session.execute(select(func.count('*')).select_from(Post).where(Post.author == self)).scalar()
+ c += session.execute(select(func.count('*')).select_from(PostUpvote).join(Post).where(Post.author == self, PostUpvote.c.is_downvote == False)).scalar()
+ c -= session.execute(select(func.count('*')).select_from(PostUpvote).join(Post).where(Post.author == self, PostUpvote.c.is_downvote == True)).scalar()
+ self.karma = c
- self.karma = c
+ return c
- @timed_cache(60)
- def strike_count(self) -> int:
- return db.session.execute(select(func.count('*')).select_from(UserStrike).where(UserStrike.user_id == self.id)).scalar()
+ ## TODO are coroutines cacheable?
+ @timed_cache(60, async_=True)
+ async def strike_count(self) -> int:
+ async with db as session:
+ return (await session.execute(select(func.count('*')).select_from(UserStrike).where(UserStrike.user_id == self.id))).scalar()
- def moderates(self, gu: Guild) -> bool:
- ## owner
- if gu.owner_id == self.id:
- return True
- ## admin or global mod
- if self.is_administrator:
- return True
- memb = db.session.execute(select(Member).where(Member.user_id == self.id, Member.guild_id == gu.id)).scalar()
+ async def moderates(self, gu: Guild) -> bool:
+ async with db as session:
+ ## owner
+ if gu.owner_id == self.id:
+ return True
+ ## admin or global mod
+ if self.is_administrator:
+ return True
+ memb = (await session.execute(select(Member).where(Member.user_id == self.id, Member.guild_id == gu.id))).scalar()
- if memb is None:
- return False
- return memb.is_moderator
+ if memb is None:
+ return False
+ return memb.is_moderator
## TODO check banship?
+ @makelist
+ def badges(self, /):
+ if self.is_administrator:
+ yield 'administrator'
+
+ badges: Callable[[], list[str]]
+
+ @classmethod
+ async def get_by_username(cls, name: str):
+ """
+ Get a user by its username,
+ """
+ user_q = select(User).where(User.username == name)
+ try:
+ if current_user:
+ user_q = user_q.where(~select(UserBlock).where(UserBlock.c.target_id == current_user.id).exists())
+ except Exception as e:
+ logger.error(f'{e}')
+
+ async with db as session:
+ user = (await session.execute(user_q)).scalar()
+ return user
+
# UserBlock table is at the top !!
## END User
@@ -346,63 +417,101 @@ class Guild(Base):
def handle(self):
return f'+{self.name}'
- def subscriber_count(self):
- return db.session.execute(select(func.count('*')).select_from(Member).where(Member.guild == self, Member.is_subscribed == True)).scalar()
+ async def subscriber_count(self):
+ async with db as session:
+ count = (await session.execute(select(func.count('*')).select_from(Member).where(Member.guild == self, Member.is_subscribed == True))).scalar()
+ return count
# utilities
- owner = relationship(User, foreign_keys=owner_id)
- posts = relationship('Post', back_populates='guild')
+ owner = relationship(User, foreign_keys=owner_id, lazy='selectin')
+ posts = relationship('Post', back_populates='guild', lazy='selectin')
- def has_subscriber(self, other: User) -> bool:
- if other is None or not other.is_authenticated:
- return False
- return bool(db.session.execute(select(Member).where(Member.user_id == other.id, Member.guild_id == self.id, Member.is_subscribed == True)).scalar())
+ async def post_count(self):
+ async with db as session:
+ return (await session.execute(select(func.count('*')).select_from(Post).where(Post.guild == self))).scalar()
- def has_exiled(self, other: User) -> bool:
- if other is None or not other.is_authenticated:
+ async def has_subscriber(self, other: User) -> bool:
+ if not want_User(other, var_name='other', prefix='Guild.has_subscriber()'):
return False
- u = db.session.execute(select(Member).where(Member.user_id == other.id, Member.guild_id == self.id)).scalar()
+ async with db as session:
+ sub_ex = (await session.execute(select(Member).where(Member.user_id == other.id, Member.guild_id == self.id, Member.is_subscribed == True))).scalar()
+ return bool(sub_ex)
+
+ async def has_exiled(self, other: User) -> bool:
+ if not want_User(other, var_name='other', prefix='Guild.has_exiled()'):
+ return False
+ async with db as session:
+ u = (await session.execute(select(Member).where(Member.user_id == other.id, Member.guild_id == self.id))).scalar()
return u.is_banned if u else False
- def allows_posting(self, other: User) -> bool:
- if self.owner is None:
- return False
- if other.is_disabled:
- return False
- mem: Member | None = db.session.execute(select(Member).where(Member.user_id == other.id, Member.guild_id == self.id)).scalar() if other else None
- if mem and mem.is_banned:
- return False
- if other.moderates(self):
+ async def allows_posting(self, other: User) -> bool:
+ async with db as session:
+ # control owner_id instead of owner: the latter causes MissingGreenletError
+ if self.owner_id is None:
+ return False
+ if other.is_disabled:
+ return False
+ mem: Member | None = (await session.execute(select(Member).where(Member.user_id == other.id, Member.guild_id == self.id))).scalar()
+ if mem and mem.is_banned:
+ return False
+ if await other.moderates(self):
+ return True
+ if self.is_restricted:
+ return (mem and mem.is_approved)
return True
- if self.is_restricted:
- return (mem and mem.is_approved)
- return True
-
- def moderators(self):
- if self.owner:
- yield ModeratorInfo(self.owner, True)
- for mem in db.session.execute(select(Member).where(Member.guild_id == self.id, Member.is_moderator == True)).scalars():
- if mem.user != self.owner and not mem.is_banned:
- yield ModeratorInfo(mem.user, False)
+ async def moderators(self):
+ async with db as session:
+ if self.owner_id:
+ owner = (await session.execute(select(User).where(User.id == self.owner_id))).scalar()
+ yield ModeratorInfo(owner, True)
+ for mem in (await session.execute(select(Member).where(Member.guild_id == self.id, Member.is_moderator == True))).scalars():
+ if mem.user != self.owner and not mem.is_banned:
+ yield ModeratorInfo(mem.user, False)
- def update_member(self, u: User | Member, /, **values):
+ async def update_member(self, u: User | Member, /, **values):
if isinstance(u, User):
- m = db.session.execute(select(Member).where(Member.user_id == u.id, Member.guild_id == self.id)).scalar()
- if m is None:
- m = db.session.execute(insert(Member).values(
- guild_id = self.id,
- user_id = u.id,
- **values
- ).returning(Member)).scalar()
+ async with db as session:
+ m = (await session.execute(select(Member).where(Member.user_id == u.id, Member.guild_id == self.id))).scalar()
if m is None:
- raise RuntimeError
- return m
+ m = (await session.execute(insert(Member).values(
+ guild_id = self.id,
+ user_id = u.id,
+ **values
+ ).returning(Member))).scalar()
+ if m is None:
+ raise RuntimeError
+ return m
else:
m = u
if len(values):
- db.session.execute(update(Member).where(Member.user_id == u.id, Member.guild_id == self.id).values(**values))
+ async with db as session:
+ session.execute(update(Member).where(Member.user_id == u.id, Member.guild_id == self.id).values(**values))
return m
+
+ 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
+
+ async def sub_info(self):
+ """
+ Guild info including subscriber count.
+ """
+ gg = self.simple_info()
+ gg['subscriber_count'] = await self.subscriber_count()
+ gg['post_count'] = await self.post_count()
+ return gg
Topic = deprecated('renamed to Guild')(Guild)
@@ -433,9 +542,9 @@ class Member(Base):
banned_until = Column(DateTime, nullable=True)
banned_message = Column(String(256), nullable=True)
- user = relationship(User, primaryjoin = lambda: User.id == Member.user_id)
- guild = relationship(Guild)
- banned_by = relationship(User, primaryjoin = lambda: User.id == Member.banned_by_id)
+ user = relationship(User, primaryjoin = lambda: User.id == Member.user_id, lazy='selectin')
+ guild = relationship(Guild, lazy='selectin')
+ banned_by = relationship(User, primaryjoin = lambda: User.id == Member.banned_by_id, lazy='selectin')
@property
def is_banned(self):
@@ -474,10 +583,14 @@ class Post(Base):
removed_reason = Column(SmallInteger, nullable=True)
# utilities
- author: Relationship[User] = relationship("User", lazy='selectin', foreign_keys=[author_id])#, back_populates="posts")
+ author: Relationship[User] = relationship("User", foreign_keys=[author_id], lazy='selectin')#, back_populates="posts")
guild: Relationship[Guild] = relationship("Guild", back_populates="posts", lazy='selectin')
- comments = relationship("Comment", back_populates="parent_post")
- upvoters = relationship("User", secondary=PostUpvote, back_populates='upvoted_posts')
+ comments = relationship("Comment", back_populates="parent_post", lazy='selectin')
+ upvoters = relationship("User", secondary=PostUpvote, back_populates='upvoted_posts', lazy='selectin')
+
+ async def comment_count(self):
+ async with db as session:
+ return (await session.execute(select(func.count('*')).select_from(Comment).where(Comment.parent_post == self))).scalar()
def topic_or_user(self) -> Guild | User:
return self.guild or self.author
@@ -489,33 +602,41 @@ class Post(Base):
def generate_slug(self) -> str:
return "slugify.slugify(self.title, max_length=64)"
- def upvotes(self) -> int:
- return (db.session.execute(select(func.count('*')).select_from(PostUpvote).where(PostUpvote.c.post_id == self.id, PostUpvote.c.is_downvote == False)).scalar()
- - db.session.execute(select(func.count('*')).select_from(PostUpvote).where(PostUpvote.c.post_id == self.id, PostUpvote.c.is_downvote == True)).scalar())
+ async def upvotes(self) -> int:
+ async with db as session:
+ upv = (await session.execute(select(func.count('*')).select_from(PostUpvote).where(PostUpvote.c.post_id == self.id, PostUpvote.c.is_downvote == False))).scalar()
+ dwv = (await session.execute(select(func.count('*')).select_from(PostUpvote).where(PostUpvote.c.post_id == self.id, PostUpvote.c.is_downvote == True))).scalar()
+ return upv - dwv
- def upvoted_by(self, user: User | AnonymousUserMixin | None):
- if not user or not user.is_authenticated:
+ async def upvoted_by(self, user: User | None):
+ if not want_User(user, var_name='user', prefix='Post.upvoted_by()'):
return 0
- v: PostUpvote | None = db.session.execute(select(PostUpvote.c).where(PostUpvote.c.voter_id == user.id, PostUpvote.c.post_id == self.id)).fetchone()
- if v:
- if v.is_downvote:
+ async with db as session:
+ v = (await session.execute(select(PostUpvote.c.is_downvote).where(PostUpvote.c.voter_id == user.id, PostUpvote.c.post_id == self.id))).fetchone()
+ if v is None:
+ return 0
+ if v == (True,):
return -1
- return 1
- return 0
+ if v == (False,):
+ return 1
+ logger.warning(f'unexpected value: {v}')
+ return 0
- def top_level_comments(self, limit=None):
- return db.session.execute(select(Comment).where(Comment.parent_comment == None, Comment.parent_post == self).order_by(Comment.created_at.desc()).limit(limit)).scalars()
+ async def top_level_comments(self, limit=None):
+ async with db as session:
+ return (await session.execute(select(Comment).where(Comment.parent_comment == None, Comment.parent_post == self).order_by(Comment.created_at.desc()).limit(limit))).scalars()
def report_url(self) -> str:
return f'/report/post/{Snowflake(self.id):l}'
- def report_count(self) -> int:
- return db.session.execute(select(func.count('*')).select_from(PostReport).where(PostReport.target_id == self.id, ~PostReport.update_status.in_((1, 2)))).scalar()
+ async def report_count(self) -> int:
+ async with db as session: return (await session.execute(select(func.count('*')).select_from(PostReport).where(PostReport.target_id == self.id, ~PostReport.update_status.in_((1, 2))))).scalar()
@classmethod
- @timed_cache(1800)
- def count(cls):
- return db.session.execute(select(func.count('*')).select_from(cls)).scalar()
+ @timed_cache(1800, async_=True)
+ async def count(cls):
+ async with db as session:
+ return (await session.execute(select(func.count('*')).select_from(cls))).scalar()
@property
def is_removed(self) -> bool:
@@ -527,8 +648,32 @@ class Post(Base):
@classmethod
def visible_by(cls, user_id: int | None):
- return or_(Post.author_id == user_id, Post.privacy.in_((0, 1)))
+ return or_(Post.author_id == user_id, Post.privacy == 0)
+ #return or_(Post.author_id == user_id, and_(Post.privacy.in_((0, 1)), ~Post.author.has_blocked_q(user_id)))
+ 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
+ )
+
+ async def feed_info_counts(self):
+ pj = self.feed_info()
+ if self.is_text_post():
+ pj['content'] = self.text_content[:181]
+ (pj['comment_count'], pj['votes'], pj['my_vote']) = await asyncio.gather(
+ self.comment_count(),
+ self.upvotes(),
+ self.upvoted_by(current_user.user)
+ )
+ return pj
class Comment(Base):
__tablename__ = 'freak_comment'
@@ -554,18 +699,31 @@ class Comment(Base):
removed_by_id = Column(BigInteger, ForeignKey('freak_user.id', name='user_banner_id'), nullable=True)
removed_reason = Column(SmallInteger, nullable=True)
- author = relationship('User', foreign_keys=[author_id])#, back_populates='comments')
- parent_post: Relationship[Post] = relationship("Post", back_populates="comments", foreign_keys=[parent_post_id])
+ author = relationship('User', foreign_keys=[author_id], lazy='selectin')#, back_populates='comments')
+ parent_post: Relationship[Post] = relationship("Post", back_populates="comments", foreign_keys=[parent_post_id], lazy='selectin')
parent_comment, child_comments = parent_children('comment', parent_remote_side=Wanted('id'))
def url(self):
return self.parent_post.url() + f'/comment/{Snowflake(self.id):l}'
+ async def is_parent_locked(self):
+ if self.is_locked:
+ return True
+ if self.parent_comment_id == None:
+ return False
+ async with db as session:
+ parent = (await session.execute(select(Comment).where(Comment.id == self.parent_comment_id))).scalar()
+ try:
+ return parent.is_parent_locked()
+ except RecursionError:
+ return True
+
def report_url(self) -> str:
return f'/report/comment/{Snowflake(self.id):l}'
- def report_count(self) -> int:
- return db.session.execute(select(func.count('*')).select_from(PostReport).where(PostReport.target_id == self.id, ~PostReport.update_status.in_((1, 2)))).scalar()
+ async def report_count(self) -> int:
+ async with db as session:
+ return (await session.execute(select(func.count('*')).select_from(PostReport).where(PostReport.target_id == self.id, ~PostReport.update_status.in_((1, 2))))).scalar()
@property
def is_removed(self) -> bool:
@@ -575,6 +733,21 @@ class Comment(Base):
def not_removed(cls):
return Post.removed_at == None
+ async def section_info(self):
+ obj = dict(
+ id = Snowflake(self.id).to_b32l(),
+ parent = dict(id=Snowflake(self.parent_comment_id)) if self.parent_comment_id else None,
+ locked = await self.is_parent_locked(),
+ created_at = want_isodate(self.created_at)
+ )
+ if self.is_removed:
+ obj['removed'] = self.removed_reason
+ else:
+ obj['content'] = self.text_content
+
+ return obj
+
+
class PostReport(Base):
__tablename__ = 'freak_postreport'
@@ -588,15 +761,16 @@ class PostReport(Base):
created_at = Column(DateTime, server_default=func.current_timestamp())
created_ip = Column(String(64), default=get_remote_addr, nullable=False)
- author = relationship('User')
+ author = relationship('User', lazy='selectin')
- def target(self):
- if self.target_type == REPORT_TARGET_POST:
- return db.session.execute(select(Post).where(Post.id == self.target_id)).scalar()
- elif self.target_type == REPORT_TARGET_COMMENT:
- return db.session.execute(select(Comment).where(Comment.id == self.target_id)).scalar()
- else:
- return self.target_id
+ async def target(self):
+ async with db as session:
+ if self.target_type == REPORT_TARGET_POST:
+ return (await session.execute(select(Post).where(Post.id == self.target_id))).scalar()
+ elif self.target_type == REPORT_TARGET_COMMENT:
+ return (await session.execute(select(Comment).where(Comment.id == self.target_id))).scalar()
+ else:
+ return self.target_id
def is_critical(self):
return self.reason_code in (
@@ -616,9 +790,10 @@ class UserStrike(Base):
issued_at = Column(DateTime, server_default=func.current_timestamp())
issued_by_id = Column(BigInteger, ForeignKey('freak_user.id'), nullable=True)
- user = relationship(User, primaryjoin= lambda: User.id == UserStrike.user_id)
- issued_by = relationship(User, primaryjoin= lambda: User.id == UserStrike.issued_by_id)
+ user = relationship(User, primaryjoin= lambda: User.id == UserStrike.user_id, lazy='selectin')
+ issued_by = relationship(User, primaryjoin= lambda: User.id == UserStrike.issued_by_id, lazy='selectin')
# PostUpvote table is at the top !!
+
diff --git a/freak/rest/__init__.py b/freak/rest/__init__.py
index 0957ad5..fc1ded8 100644
--- a/freak/rest/__init__.py
+++ b/freak/rest/__init__.py
@@ -1,68 +1,472 @@
+from __future__ import annotations
+import datetime
+import sys
+from typing import Iterable, TypeVar
+import logging
-from flask import Blueprint, redirect, url_for
-from flask_restx import Resource
-from sqlalchemy import select
-from suou import Snowflake
+from quart import render_template, session
+from quart import abort, Blueprint, redirect, request, url_for
+from pydantic import BaseModel, Field
+from quart_auth import current_user, login_required, login_user, logout_user
+from quart_schema import validate_request
+from quart_wtf.csrf import generate_csrf
+from sqlalchemy import delete, insert, select
+from suou import Snowflake, deprecated, makelist, not_implemented, want_isodate
+
+from suou.classtools import MISSING, MissingType
+from werkzeug.security import check_password_hash
+from suou.quart import add_rest
+
+from freak.accounts import LoginStatus, check_login
+from freak.algorithms import public_timeline, top_guilds_query, topic_timeline, user_timeline
+from freak.search import SearchQuery
+
+from ..models import Comment, Guild, Post, PostUpvote, User, db
+from .. import UserLoader, app, app_config, __version__ as freak_version, csrf
+
+logger = logging.getLogger(__name__)
+_T = TypeVar('_T')
+
+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
+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')
-rest = Api(rest_bp)
+@bp.get('/health')
+async def health():
+ async with db as session:
+ hi = dict(
+ version=freak_version,
+ name = app_config.app_name,
+ post_count = await Post.count(),
+ user_count = await User.active_count(),
+ me = Snowflake(current_user.id).to_b32l() if current_user else None,
+ color_theme = current_user.color_theme if current_user else 0
+ )
-auth_required = require_auth(User, db)
+ return hi
-@rest.route('/nurupo')
-class Nurupo(Resource):
- def get(self):
- return dict(nurupo='ga')
+@bp.get('/oath')
+async def oath():
+ try:
+ ## pull csrf token from session
+ csrf_tok = session['csrf_token']
+ except Exception as e:
+ try:
+ logger.warning('CSRF token regenerated!')
+ csrf_tok = session['csrf_token'] = generate_csrf()
+ except Exception as e2:
+ print(e, e2)
+ abort(503, "csrf_token is null")
+
+ return dict(
+ ## XXX might break any time!
+ csrf_token= csrf_tok
+ )
## TODO coverage of REST is still partial, but it's planned
## to get complete sooner or later
## XXX there is a bug in suou.sqlalchemy.auth_required() — apparently, /user/@me does not
-## redirect, neither is able to get user injected.
+## 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
-@rest.route('/user/@me')
-class UserInfoMe(Resource):
- @auth_required(required=True)
- def get(self, user: User):
- return redirect(url_for('rest.UserInfo', user.id)), 302
+## USERS ##
-@rest.route('/user/')
-class UserInfo(Resource):
- def get(self, id: int):
- ## TODO sanizize REST to make blocked users inaccessible
- u: User | None = db.session.execute(select(User).where(User.id == id)).scalar()
- if u is None:
- return dict(error='User not found'), 404
- uj = dict(
+@bp.get('/user/@me')
+@login_required
+async def get_user_me():
+ return redirect(url_for(f'rest.user_get', id=current_user.id)), 302
+
+def _user_info(u: User):
+ return dict(
id = f'{Snowflake(u.id):l}',
username = u.username,
display_name = u.display_name,
- joined_at = u.joined_at.isoformat('T'),
+ joined_at = want_isodate(u.joined_at),
karma = u.karma,
- age = u.age()
+ age = u.age(),
+ biography=u.biography,
+ badges = u.badges()
)
- return dict(users={f'{Snowflake(id):l}': uj})
+@bp.get('/user/')
+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/')
-class SinglePost(Resource):
- def get(self, id: int):
- p: Post | None = db.session.execute(select(Post).where(Post.id == id)).scalar()
+@bp.get('/user//feed')
+async def user_feed_get(id: int):
+ async with db as session:
+ u: User | None = (await session.execute(select(User).where(User.id == id))).scalar()
+ if u is None:
+ return dict(error='User not found'), 404
+ uj = _user_info(u)
+
+ feed = []
+ algo = user_timeline(u)
+ posts = await db.paginate(algo)
+ async for p in posts:
+ feed.append(await p.feed_info_counts())
+
+ return dict(users={f'{Snowflake(id):l}': uj}, feed=feed)
+
+@bp.get('/user/@')
+async def resolve_user(username: str):
+ async with db as session:
+ uid: User | None = (await session.execute(select(User.id).select_from(User).where(User.username == username))).scalar()
+ if uid is None:
+ abort(404, 'User not found')
+ return redirect(url_for('rest.user_get', id=uid)), 302
+
+@bp.get('/user/@/feed')
+async def resolve_user_feed(username: str):
+ async with db as session:
+ uid: User | None = (await session.execute(select(User.id).select_from(User).where(User.username == username))).scalar()
+ if uid is None:
+ abort(404, 'User not found')
+ return redirect(url_for('rest.user_feed_get', id=uid)), 302
+
+## POSTS ##
+
+@bp.get('/post/')
+async def get_post(id: int):
+ async with db as session:
+ p: Post | None = (await session.execute(select(Post).where(Post.id == id))).scalar()
if p is None:
return dict(error='Not found'), 404
pj = dict(
id = f'{Snowflake(p.id):l}',
title = p.title,
author = p.author.simple_info(),
- to = p.topic_or_user().handle(),
+ to = p.topic_or_user().simple_info(typed=True),
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
+
+ pj['comment_count'] = await p.comment_count()
+ pj['votes'] = await p.upvotes()
+ pj['my_vote'] = await p.upvoted_by(current_user.user)
+
+ return dict(posts={f'{Snowflake(id):l}': pj})
+
+class VoteIn(BaseModel):
+ vote: int
+
+@bp.post('/post//upvote')
+@validate_request(VoteIn)
+async def upvote_post(id: int, data: VoteIn):
+ async with db as session:
+ p: Post | None = (await session.execute(select(Post).where(Post.id == id))).scalar()
+
+ if p is None:
+ return { 'status': 404, 'error': 'Post not found' }, 404
+
+ cur_score = await p.upvoted_by(current_user.user)
+
+ match (data.vote, cur_score):
+ case (1, 0) | (1, -1):
+ await session.execute(delete(PostUpvote).where(PostUpvote.c.post_id == p.id, PostUpvote.c.voter_id == current_user.id, PostUpvote.c.is_downvote == True))
+ await session.execute(insert(PostUpvote).values(post_id = p.id, voter_id = current_user.id, is_downvote = False))
+ case (0, _):
+ await session.execute(delete(PostUpvote).where(PostUpvote.c.post_id == p.id, PostUpvote.c.voter_id == current_user.id))
+ case (-1, 1) | (-1, 0):
+ await session.execute(delete(PostUpvote).where(PostUpvote.c.post_id == p.id, PostUpvote.c.voter_id == current_user.id, PostUpvote.c.is_downvote == False))
+ await session.execute(insert(PostUpvote).values(post_id = p.id, voter_id = current_user.id, is_downvote = True))
+ case (1, 1) | (1, -1):
+ pass
+ case _:
+ await session.rollback()
+ return { 'status': 400, 'error': 'Invalid score' }, 400
+
+ await session.commit()
+ return { 'votes': await p.upvotes() }
+
+## COMMENTS ##
+
+@bp.get('/post//comments')
+async def post_comments (id: int):
+ async with db as session:
+ p: Post | None = (await session.execute(select(Post).where(Post.id == id))).scalar()
+
+ if p is None:
+ return { 'status': 404, 'error': 'Post not found' }, 404
+
+ l = []
+ for com in await p.top_level_comments():
+ com: Comment
+ l.append(await com.section_info())
+
+ return dict(has=l)
+
+
+
+## GUILDS ##
+
+async def _guild_info(gu: Guild):
+ return dict(
+ id = f'{Snowflake(gu.id):l}',
+ name = gu.name,
+ display_name = gu.display_name,
+ description = gu.description,
+ created_at = want_isodate(gu.created_at),
+ badges = []
+ )
+
+@bp.get('/guild/')
+async def guild_info_id(gid: int):
+ async with db as session:
+ gu: Guild | None = (await session.execute(select(Guild).where(Guild.id == gid))).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/@')
+async def guild_info_only(gname: str):
+ async with db as session:
+ gu: Guild | None = (await session.execute(select(Guild).where(Guild.name == gname))).scalar()
+
+ if gu is None:
+ return dict(error='Not found'), 404
+ gj = await _guild_info(gu)
+
+ return dict(guilds={f'{Snowflake(gu.id):l}': gj})
+
+
+@bp.get('/guild/@/feed')
+async def guild_feed(gname: str):
+ async with db as session:
+ gu: Guild | None = (await session.execute(select(Guild).where(Guild.name == gname))).scalar()
+
+ if gu is None:
+ return dict(error='Not found'), 404
+ gj = await _guild_info(gu)
+ feed = []
+ algo = topic_timeline(gname)
+ posts = await db.paginate(algo)
+ async for p in posts:
+ feed.append(await p.feed_info_counts())
+
+ return dict(guilds={f'{Snowflake(gu.id):l}': gj}, feed=feed)
+
+
+## CREATE ##
+
+class CreateIn(BaseModel):
+ title: str
+ content: str
+ privacy: int = Field(default=0, ge=0, lt=4)
+
+@bp.post('/guild/@')
+@login_required
+@validate_request(CreateIn)
+async def guild_post(data: CreateIn, gname: str):
+ async with db as session:
+ user = current_user.user
+ gu: Guild | None = (await session.execute(select(Guild).where(Guild.name == gname))).scalar()
+
+ if gu is None:
+ return dict(error='Not found'), 404
+ if await gu.has_exiled(current_user.user):
+ return dict(error=f'You are banned from +{gname}'), 403
+ if not await gu.allows_posting(current_user.user):
+ return dict(error=f'You can\'t post on +{gname}'), 403
+
+ try:
+ new_post_id: int = (await session.execute(insert(Post).values(
+ author_id = user.id,
+ topic_id = gu.id,
+ privacy = data.privacy,
+ title = data.title,
+ text_content = data.text
+ ).returning(Post.id))).scalar()
+
+ session.commit()
+ return dict(id=Snowflake(new_post_id).to_b32l()), 200
+ except Exception:
+ sys.excepthook(*sys.exc_info())
+ return {'error': 'Internal Server Error'}, 500
+
+## 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
+
+
+## HOME ##
+
+@bp.get('/home/feed')
+@login_required
+async def home_feed():
+ async with db as session:
+ me = current_user.user
+ posts = await db.paginate(public_timeline())
+ feed = []
+ async for post in posts:
+ feed.append(await post.feed_info_counts())
+
+ return dict(feed=feed)
+
+
+@bp.get('/top/guilds')
+async def top_guilds():
+ async with db as session:
+ top_g = [await x.sub_info() for x in
+ (await session.execute(top_guilds_query().limit(10))).scalars()]
+
+ return dict(has=top_g)
+
+## SEARCH ##
+
+class QueryIn(BaseModel):
+ query: str
+
+@bp.post('/search/top')
+@validate_request(QueryIn)
+async def search_top(data: QueryIn):
+ async with db as session:
+ sq = SearchQuery(data.query)
+
+ result = (await session.execute(sq.select(Post, [Post.title]).limit(20))).scalars()
+
+ return dict(has = [p.feed_info() for p in result])
+
+
+## SUGGEST
+
+
+@bp.post("/suggest/guild")
+@validate_request(QueryIn)
+async def suggest_guild(data: QueryIn):
+ if not data.query.isidentifier():
+ return dict(has=[])
+ async with db as session:
+ sq = select(Guild).where(Guild.name.like(data.query + "%"))
+
+ result: Iterable[Guild] = (await session.execute(sq.limit(10))).scalars()
+
+ return dict(has = [g.simple_info() for g in result if await g.allows_posting(current_user.user)])
+
+
+## SETTINGS
+
+@bp.get("/settings/appearance")
+@login_required
+async def get_settings_appearance():
+ return dict(
+ color_theme = current_user.user.color_theme
+ )
+
+
+class SettingsAppearanceIn(BaseModel):
+ color_theme : int | None = None
+ color_scheme : int | None = None
+
+
+def _missing_or(obj: _T | MissingType, obj2: _T) -> _T:
+ if obj is None:
+ return obj2
+ return obj
+
+@bp.patch("/settings/appearance")
+@login_required
+@validate_request(SettingsAppearanceIn)
+async def patch_settings_appearance(data: SettingsIn):
+ u = current_user.user
+ if u is None:
+ abort(401)
+
+ u.color_theme = (
+ _missing_or(data.color_theme, u.color_theme % (1 << 8)) % 256 +
+ _missing_or(data.color_scheme, u.color_theme >> 8) << 8
+ )
+ current_user.session.add(u)
+ await current_user.session.commit()
+
+ return '', 204
+
+## TERMS
+
+@bp.get('/about/about')
+async def about_about():
+ return dict(
+ content=await render_template("about.md",
+ quart_version=quart_version,
+ sa_version=sa_version,
+ python_version=sys.version.split()[0]
+ )
+ )
+
+@bp.get('/about/terms')
+async def terms():
+ return dict(
+ content=await render_template("terms.md")
+ )
+
+@bp.get('/about/privacy')
+async def privacy():
+ return dict(
+ content=await render_template("privacy.md")
+ )
+
+@bp.get('/about/rules')
+async def rules():
+ return dict(
+ content=await render_template("rules.md")
+ )
diff --git a/freak/search.py b/freak/search.py
index b4b7c27..6b357be 100644
--- a/freak/search.py
+++ b/freak/search.py
@@ -2,12 +2,8 @@
from typing import Iterable
-from flask import flash, g
from sqlalchemy import Column, Select, select, or_
-from .models import Guild, User, db
-
-
class SearchQuery:
keywords: Iterable[str]
@@ -27,24 +23,3 @@ class SearchQuery:
return sq
-def find_guild_or_user(name: str) -> str | None:
- """
- Used in 404 error handler.
-
- Returns an URL to redirect or None for no redirect.
- """
-
- if hasattr(g, 'no_user'):
- return None
-
- gu = db.session.execute(select(Guild).where(Guild.name == name)).scalar()
- if gu is not None:
- flash(f'There is nothing at /{name}. Luckily, a guild with name {gu.handle()} happens to exist. Next time, remember to add + before!')
- return gu.url()
-
- user = db.session.execute(select(User).where(User.username == name)).scalar()
- if user is not None:
- flash(f'There is nothing at /{name}. Luckily, a user named {user.handle()} happens to exist. Next time, remember to add @ before!')
- return user.url()
-
- return None
\ No newline at end of file
diff --git a/freak/static/admin/style.css b/freak/static/admin/style.css
new file mode 100644
index 0000000..990a732
--- /dev/null
+++ b/freak/static/admin/style.css
@@ -0,0 +1,652 @@
+/**
+ Static version of style.css from v0.4.0
+ expressly for admin pages, skimmed
+ */
+
+ @charset "UTF-8";
+* {
+ box-sizing: border-box; }
+
+:root {
+ --c0-accent: #ff7300;
+ --c1-accent: #ff7300;
+ --c2-accent: #f837ce;
+ --c3-accent: #38b8ff;
+ --c4-accent: #ffe338;
+ --c5-accent: #78f038;
+ --c6-accent: #ff9aae;
+ --c7-accent: #606080;
+ --c8-accent: #aeaac0;
+ --c9-accent: #3ae0b8;
+ --c10-accent: #8828ea;
+ --c11-accent: #1871d8;
+ --c12-accent: #885a18;
+ --c13-accent: #38a856;
+ --c14-accent: #ff3018;
+ --c15-accent: #ff1668;
+ --light-text-primary: #181818;
+ --light-text-alt: #444;
+ --light-border: #999;
+ --light-success: #73af00;
+ --light-error: #e04830;
+ --light-warning: #dea800;
+ --light-canvas: #eaecee;
+ --light-background: #f9f9f9;
+ --light-bg-sharp: #fdfdff;
+ --dark-text-primary: #e8e8e8;
+ --dark-text-alt: #c0cad3;
+ --dark-border: #777;
+ --dark-success: #93cf00;
+ --dark-error: #e04830;
+ --dark-warning: #dea800;
+ --dark-canvas: #0a0a0e;
+ --dark-background: #181a21;
+ --dark-bg-sharp: #080808;
+ --accent: var(--c0-accent);
+ --light-accent: var(--accent);
+ --dark-accent: var(--accent);
+ --text-primary: var(--light-text-primary);
+ --text-alt: var(--light-text-alt);
+ --border: var(--light-border);
+ --success: var(--light-success);
+ --error: var(--light-error);
+ --warning: var(--light-warning);
+ --canvas: var(--light-canvas);
+ --background: var(--light-background);
+ --bg-sharp: var(--light-bg-sharp); }
+
+@media (prefers-color-scheme: dark) {
+ :root {
+ --text-primary: var(--dark-text-primary);
+ --text-alt: var(--dark-text-alt);
+ --border: var(--dark-border);
+ --success: var(--dark-success);
+ --error: var(--dark-error);
+ --warning: var(--dark-warning);
+ --canvas: var(--dark-canvas);
+ --background: var(--dark-background);
+ --bg-sharp: var(--dark-bg-sharp); } }
+
+.color-scheme-light {
+ --text-primary: var(--light-text-primary);
+ --text-alt: var(--light-text-alt);
+ --border: var(--light-border);
+ --success: var(--light-success);
+ --error: var(--light-error);
+ --warning: var(--light-warning);
+ --canvas: var(--light-canvas);
+ --background: var(--light-background);
+ --bg-sharp: var(--light-bg-sharp); }
+
+.color-scheme-dark {
+ --text-primary: var(--dark-text-primary);
+ --text-alt: var(--dark-text-alt);
+ --border: var(--dark-border);
+ --success: var(--dark-success);
+ --error: var(--dark-error);
+ --warning: var(--dark-warning);
+ --canvas: var(--dark-canvas);
+ --background: var(--dark-background);
+ --bg-sharp: var(--dark-bg-sharp); }
+
+.color-theme-1 {
+ --accent: var(--c1-accent); }
+
+.color-theme-2 {
+ --accent: var(--c2-accent); }
+
+.color-theme-3 {
+ --accent: var(--c3-accent); }
+
+.color-theme-4 {
+ --accent: var(--c4-accent); }
+
+.color-theme-5 {
+ --accent: var(--c5-accent); }
+
+.color-theme-6 {
+ --accent: var(--c6-accent); }
+
+.color-theme-7 {
+ --accent: var(--c7-accent); }
+
+.color-theme-8 {
+ --accent: var(--c8-accent); }
+
+.color-theme-9 {
+ --accent: var(--c9-accent); }
+
+.color-theme-10 {
+ --accent: var(--c10-accent); }
+
+.color-theme-11 {
+ --accent: var(--c11-accent); }
+
+.color-theme-12 {
+ --accent: var(--c12-accent); }
+
+.color-theme-13 {
+ --accent: var(--c13-accent); }
+
+.color-theme-14 {
+ --accent: var(--c14-accent); }
+
+.color-theme-15 {
+ --accent: var(--c15-accent); }
+
+body, input, select, button {
+ font-family: system-ui, -apple-system, BlinkMacSystemFont, "Noto Sans", sans-serif; }
+
+body {
+ line-height: 1.5;
+ font-size: 18px; }
+
+input, button, select {
+ font-family: inherit;
+ font-size: inherit;
+ line-height: inherit; }
+
+textarea {
+ font-family: monospace; }
+
+input:not([type="submit"], [type="button"], [type="reset"]), textarea {
+ background: var(--bg-sharp);
+ color: var(--text-main);
+ border: var(--border);
+ border-radius: 9px; }
+
+body {
+ color: var(--text-primary);
+ background-color: var(--canvas); }
+
+.card {
+ background-color: var(--background);
+ border: var(--canvas) 1px solid;
+ border-radius: 9px;
+ margin: 12px auto;
+ padding: 12px;
+ max-width: 960px; }
+
+.centered {
+ text-align: center;
+ font-size: 110%; }
+
+a:link, a:visited {
+ color: var(--accent);
+ transition: ease 5s; }
+
+img {
+ max-width: 100%;
+ max-height: 100vh; }
+
+.faint {
+ opacity: .75; }
+ strong .faint {
+ font-weight: 400; }
+
+.callout {
+ color: var(--text-alt); }
+
+.success {
+ color: var(--success); }
+
+.error {
+ color: var(--error); }
+
+.warning {
+ color: var(--warning); }
+
+body {
+ margin: 0; }
+
+.content-container {
+ display: flex;
+ flex-direction: row-reverse;
+ align-items: start;
+ justify-content: flex-start; }
+
+.content-nav {
+ width: 320px;
+ font-size: smaller; }
+
+.content-main {
+ flex: 1; }
+
+main {
+ min-height: 70vh;
+ margin: 12px auto; }
+
+header.header {
+ background-color: var(--background);
+ display: flex;
+ justify-content: space-between;
+ overflow: hidden;
+ height: 3em;
+ padding: .75em 1.5em;
+ line-height: 1; }
+ header.header h1 {
+ margin: 0;
+ padding: 0;
+ font-size: 1.5em; }
+ header.header .metanav {
+ align-self: flex-end;
+ font-size: 1.5em;
+ margin: auto;
+ margin-inline-start: 2em; }
+ header.header .metanav ul {
+ list-style: none;
+ padding: 0;
+ margin: 0; }
+ header.header .metanav ul > li {
+ margin: 0 6px; }
+ header.header .metanav ul, header.header .metanav ul > li {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ justify-content: flex-end; }
+ header.header .metanav, header.header .metanav > ul, header.header .metanav > ul > li:has(.mini-search-bar) {
+ flex: 1; }
+ header.header .metanav ul > li span {
+ color: var(--text-primary);
+ font-size: .6em; }
+ header.header .header-username > * {
+ display: block;
+ font-size: .5em;
+ line-height: 1.25; }
+ header.header .header-username .icon {
+ font-size: inherit; }
+ header.header a {
+ text-decoration: none; }
+ header.header .mini-search-bar {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ justify-content: flex-end;
+ flex: 1;
+ font-size: 1.2rem; }
+ header.header .mini-search-bar [type="search"] {
+ flex: 1;
+ border-radius: 0;
+ border: 0;
+ border-bottom: 2px solid var(--border);
+ background-color: inherit;
+ focus-background-color: var(--bg-sharp);
+ focus-border-color: var(--accent); }
+ header.header .mini-search-bar [type="submit"] {
+ height: 0;
+ width: 0;
+ padding: 0;
+ margin: 0;
+ border-radius: 0;
+ opacity: 0;
+ overflow: hidden; }
+ header.header .mini-search-bar + a {
+ display: none; }
+
+aside.card {
+ overflow: hidden; }
+ aside.card > :is(h1, h2, h3, h4, h5, h6):first-child {
+ background-color: var(--accent);
+ padding: 6px 12px;
+ margin: -12px -12px 0 -12px;
+ position: relative; }
+ aside.card > :is(h1, h2, h3, h4, h5, h6):first-child a {
+ color: inherit;
+ text-decoration: underline; }
+ aside.card > ul {
+ list-style: none;
+ margin: 0;
+ padding: 0; }
+ aside.card > ul > li {
+ border-bottom: 1px solid var(--canvas);
+ padding: 12px; }
+ aside.card > ul > li:last-child {
+ border-bottom: none; }
+ aside.card > p {
+ padding: 12px;
+ margin: 0; }
+
+.flash {
+ border-color: yellow;
+ background-color: #fff00040; }
+
+ul.timeline {
+ list-style: none;
+ padding: 0 1em; }
+ ul.timeline > li {
+ border-bottom: 1px solid var(--border);
+ margin-bottom: 6px; }
+ ul.timeline > li:last-child {
+ border-bottom: 0;
+ margin-bottom: 0; }
+
+ul.inline {
+ list-style: none;
+ padding: 0;
+ margin: 0;
+ display: inline; }
+ ul.inline > li {
+ display: inline; }
+ ul.inline > li::before {
+ content: ' · ';
+ margin: 0 .5em; }
+ ul.inline > li:first-child::before {
+ content: '';
+ margin: 0; }
+
+ul.grid {
+ list-style: none;
+ padding: 0;
+ display: grid;
+ grid-template-columns: 1fr 1fr 1fr 1fr;
+ grid-template-rows: auto; }
+ ul.grid > li {
+ border: 1px solid var(--border);
+ border-radius: .5em;
+ padding: .5em;
+ margin: 1em .5em;
+ text-align: center; }
+ ul.grid > li small {
+ display: block; }
+
+ul.message-options {
+ color: var(--text-alt);
+ list-style: none;
+ padding: 0;
+ font-size: smaller; }
+
+.post-frame {
+ margin-left: 3em;
+ position: relative;
+ min-height: 6em;
+ clear: right; }
+ [dir="rtl"] .post-frame {
+ margin-left: 0;
+ margin-right: 3em; }
+ .post-frame .message-options {
+ margin-bottom: 1em; }
+ .post-frame .message-stats {
+ position: absolute;
+ left: -3em;
+ top: 0;
+ display: flex;
+ flex-direction: column;
+ width: 2em;
+ text-align: center;
+ line-height: 1.0; }
+ [dir="rtl"] .post-frame .message-stats {
+ right: -3em;
+ left: unset; }
+ .post-frame .message-stats > * {
+ display: flex;
+ flex-direction: column; }
+ .post-frame .message-stats strong {
+ font-size: smaller; }
+ .post-frame .message-stats a {
+ text-decoration: none;
+ margin: .25em 0; }
+
+.message-meta {
+ font-size: smaller;
+ color: var(--text-alt); }
+
+.shorten {
+ max-height: 18em;
+ overflow-y: hidden;
+ position: relative; }
+ .shorten::after {
+ content: '';
+ position: absolute;
+ z-index: 10;
+ top: 16em;
+ left: 0;
+ width: 100%;
+ height: 2em;
+ display: block;
+ background: linear-gradient(to bottom, rgba(0, 0, 0, 0) 0%, var(--background) 100%); }
+
+.comments-button .comment-count {
+ display: inline-block;
+ min-width: 1em;
+ text-align: center; }
+
+i.icon {
+ font-size: inherit;
+ font-style: normal; }
+
+form.boundaryless {
+ flex: 1;
+ background: transparent;
+ color: inherit;
+ border: 0;
+ border-top: 1px solid var(--border);
+ border-bottom: 1px solid var(--border); }
+ form.boundaryless dd {
+ display: flex;
+ flex-direction: row;
+ width: 100%;
+ box-sizing: border-box;
+ margin: 0; }
+ form.boundaryless textarea, form.boundaryless input[type="text"] {
+ width: 100%; }
+ form.boundaryless textarea {
+ min-height: 4em; }
+ form.boundaryless p input[type="text"] {
+ width: unset; }
+
+.big-search-bar form {
+ display: flex;
+ flex-direction: row;
+ font-size: 1.6em;
+ width: 80%;
+ margin: auto; }
+ .big-search-bar form > [type="search"] {
+ flex: 1;
+ border-bottom: 2px solid var(--border); }
+
+footer.footer {
+ text-align: center;
+ font-size: smaller; }
+ footer.footer ul {
+ list-style: none;
+ padding: 0;
+ margin: 0; }
+ footer.footer ul > li {
+ display: inline-block;
+ margin: 0 2em; }
+
+textarea.comment-area {
+ width: 100%; }
+
+button, [type="submit"], [type="reset"], [type="button"] {
+ background-color: transparent;
+ color: var(--accent);
+ border: 1px solid var(--accent);
+ border-radius: 6px;
+ padding: 6px 12px;
+ margin: 6px;
+ cursor: pointer; }
+ button.primary, [type="submit"].primary, [type="reset"].primary, [type="button"].primary {
+ background-color: var(--accent);
+ color: var(--background); }
+ button[disabled], [type="submit"][disabled], [type="reset"][disabled], [type="button"][disabled] {
+ opacity: .5;
+ cursor: not-allowed;
+ border: var(--border);
+ color: var(--border); }
+ button.primary[disabled], [type="submit"].primary[disabled], [type="reset"].primary[disabled], [type="button"].primary[disabled] {
+ color: var(--background);
+ background-color: var(--border); }
+ button:first-child, [type="submit"]:first-child, [type="reset"]:first-child, [type="button"]:first-child {
+ margin-inline-start: 0; }
+ button:last-child, [type="submit"]:last-child, [type="reset"]:last-child, [type="button"]:last-child {
+ margin-inline-end: 0; }
+
+.button-row-right {
+ display: flex;
+ flex-direction: row;
+ justify-content: flex-end; }
+
+.comment-frame {
+ border: 1px solid var(--border);
+ background: var(--background);
+ padding: 12px 12px 6px;
+ border-radius: 24px;
+ border-start-start-radius: 0;
+ min-width: 50%;
+ width: 0;
+ margin-inline-end: auto;
+ margin-bottom: 12px;
+ position: relative; }
+ .comment-frame::before {
+ content: '';
+ border: 1px solid var(--border);
+ border-inline-end: 0;
+ border-bottom: 0;
+ background: var(--background);
+ height: 1em;
+ width: 1em;
+ position: absolute;
+ left: calc(-1px - .5em);
+ top: -1px;
+ transform: skewX(45deg); }
+ li:has(> .comment-frame) {
+ list-style: none; }
+
+.border-accent {
+ border: var(--accent) 1px solid;
+ display: inline-flex;
+ align-items: center;
+ padding: 0 4px; }
+
+.round {
+ border-radius: 1em; }
+
+.done {
+ opacity: .5; }
+
+button.card {
+ width: 100%;
+ padding: .5em 1em;
+ background-color: transparent;
+ border-color: var(--accent);
+ color: var(--accent);
+ border-radius: 1em; }
+ button.card.primary {
+ background-color: var(--accent);
+ color: var(--background); }
+
+.big_icon {
+ display: block;
+ margin: 12px auto;
+ font-size: 36px;
+ text-align: center; }
+
+textarea.create_text {
+ min-height: 8em; }
+ form.boundaryless textarea.create_text {
+ min-height: 8em; }
+
+:is(input, select, textarea).fullwidth {
+ width: 100%;
+ padding: 0; }
+
+label:has([type="checkbox"]:not(:checked)) {
+ opacity: .75; }
+
+.content {
+ margin: 2em auto;
+ max-width: 1280px; }
+
+blockquote {
+ padding-left: 1em;
+ border-left: 4px solid var(--border);
+ margin-left: 0; }
+ [dir="rtl"] blockquote {
+ padding-left: 0;
+ border-left: 0;
+ padding-right: 1em;
+ border-right: 4px solid var(--border); }
+
+.message-content p {
+ margin: 4px 0; }
+
+.message-content ul {
+ margin: 4px 0;
+ padding: 0;
+ padding-inline-start: 1.5em; }
+ .message-content ul > li {
+ margin: 0; }
+
+h1, h2, h3, h4, h5, h6 {
+ font-weight: 500; }
+
+@media screen and (max-width: 800px) {
+ .content-container {
+ display: block; }
+ .content-nav, .content-main {
+ width: 100%; }
+ ul.grid {
+ grid-template-columns: 1fr 1fr; }
+ .nomobile {
+ display: none !important; }
+ body {
+ position: relative; }
+ footer.mobile-nav {
+ position: sticky;
+ bottom: 0;
+ left: 0;
+ width: 100%;
+ overflow: hidden;
+ margin: 0;
+ padding: 0;
+ background-color: var(--background);
+ box-shadow: 0 0 6px var(--border);
+ z-index: 150; }
+ footer.mobile-nav > ul {
+ display: flex;
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ flex-direction: row;
+ align-items: stretch;
+ justify-content: stretch; }
+ footer.mobile-nav > ul > li {
+ flex: 1;
+ padding: .5em;
+ margin: 0;
+ text-align: center; }
+ footer.mobile-nav > ul > li a {
+ text-decoration: none; }
+ footer.mobile-nav > ul > li .icon {
+ font-size: 2rem; }
+ .content-nav {
+ margin: 1em;
+ width: unset; }
+ header.header h1 {
+ margin-top: 4px;
+ margin-left: 6px; }
+ .content-header {
+ text-align: center; }
+ .big-search-bar form {
+ flex-direction: column; }
+ .big-search-bar form [type="submit"] {
+ width: unset;
+ margin: 12px auto; } }
+
+@media screen and (max-width: 960px) {
+ .header-username {
+ display: none; }
+ header.header {
+ padding: .5em .5em; }
+ header.header .mini-search-bar {
+ display: none; }
+ header.header .mini-search-bar + a {
+ display: inline-block; }
+ header.header ul > li:has(.mini-search-bar) {
+ flex: unset; } }
+
+@media screen and (min-width: 801px) {
+ .mobileonly {
+ display: none !important; } }
+
diff --git a/freak/templates/about.html b/freak/templates/about.html
index a7d0840..0474652 100644
--- a/freak/templates/about.html
+++ b/freak/templates/about.html
@@ -9,28 +9,9 @@
{% block content %}
-
Stats
-
- No. of posts: {{ post_count }}
- No. of active users (posters in the last 30 days): {{ user_count }}
-
-
-
Software versions
-
- Python : {{ python_version }}
- SQLAlchemy : {{ sa_version }}
- Flask : {{ flask_version }}
- {{ app_name }} : {{ app_version }}
-
-
-
License
-
Source code is available at: https://github.com/yusurko/freak
-
- {% if impressum %}
-
Legal Contacts
-
{{ impressum }}
- {% endif %}
-
+{% filter to_markdown %}
+{% include "about.md" %}
+{% endfilter %}
{% endblock %}
diff --git a/freak/templates/about.md b/freak/templates/about.md
new file mode 100644
index 0000000..a3bd15e
--- /dev/null
+++ b/freak/templates/about.md
@@ -0,0 +1,25 @@
+
+## Stats
+
+* \# of posts: **{{ post_count }}**
+* \# of active users (posters in the last 30 days): **{{ user_count }}**
+
+## Software versions
+
+* **Python**: {{ python_version }}
+* **SQLAlchemy**: {{ sa_version }}
+* **Quart**: {{ quart_version }}
+* **{{ app_name }}**: {{ app_version }}
+
+## License
+
+Source code is available at:
+
+{% if impressum %}
+## Legal Contacts
+
+```
+{{ impressum }}
+```
+{% endif %}
+
diff --git a/freak/templates/admin/400.html b/freak/templates/admin/400.html
new file mode 100644
index 0000000..302b34c
--- /dev/null
+++ b/freak/templates/admin/400.html
@@ -0,0 +1,10 @@
+{% extends "admin/admin_base.html" %}
+
+
+{% block content %}
+
+{% endblock %}
diff --git a/freak/templates/admin/403.html b/freak/templates/admin/403.html
new file mode 100644
index 0000000..d48cc53
--- /dev/null
+++ b/freak/templates/admin/403.html
@@ -0,0 +1,12 @@
+{% extends "admin/admin_base.html" %}
+{% from "macros/title.html" import title_tag with context %}
+
+
+
+{% block content %}
+
+{% endblock %}
diff --git a/freak/templates/admin/404.html b/freak/templates/admin/404.html
new file mode 100644
index 0000000..b7dd7ff
--- /dev/null
+++ b/freak/templates/admin/404.html
@@ -0,0 +1,12 @@
+{% extends "admin/admin_base.html" %}
+{% from "macros/title.html" import title_tag with context %}
+
+
+
+{% block content %}
+
+{% endblock %}
diff --git a/freak/templates/admin/500.html b/freak/templates/admin/500.html
new file mode 100644
index 0000000..23e2f50
--- /dev/null
+++ b/freak/templates/admin/500.html
@@ -0,0 +1,11 @@
+{% extends "admin/admin_base.html" %}
+
+
+{% block content %}
+
+{% endblock %}
+
diff --git a/freak/templates/admin/admin_base.html b/freak/templates/admin/admin_base.html
index 5288f35..46d9655 100644
--- a/freak/templates/admin/admin_base.html
+++ b/freak/templates/admin/admin_base.html
@@ -5,7 +5,7 @@
{{ title_tag("Admin") }}
-
+
{% for private_style in private_styles %}
{% endfor %}
diff --git a/freak/templates/admin/admin_report_detail.html b/freak/templates/admin/admin_report_detail.html
index 370134e..6d14ea2 100644
--- a/freak/templates/admin/admin_report_detail.html
+++ b/freak/templates/admin/admin_report_detail.html
@@ -29,6 +29,6 @@
Remove
Strike
{% endif %}
- Put on hold
+ Put on hold
{% endblock %}
diff --git a/freak/templates/admin/admin_users.html b/freak/templates/admin/admin_users.html
index 31079c4..d55d9f8 100644
--- a/freak/templates/admin/admin_users.html
+++ b/freak/templates/admin/admin_users.html
@@ -9,7 +9,7 @@
{%- if u.is_administrator %}
(Admin)
{% endif -%}
- {% if u == current_user %}
+ {% if u == current_user.user %}
(You)
{% endif -%}
diff --git a/freak/templates/base.html b/freak/templates/base.html
index a4eb90c..96a9916 100644
--- a/freak/templates/base.html
+++ b/freak/templates/base.html
@@ -2,7 +2,6 @@
-
{% from "macros/icon.html" import icon with context %}
{% block title %}
@@ -13,7 +12,7 @@
This Service is available "AS IS", with NO WARRANTY, explicit or implied.
Sakuragasaki46 is NOT legally liable for Your use of the Service.
This service is age-restricted; do not access if underage.
- More info: https://{{ domain_name }}/terms
+ More info: https://{{ server_name }}/terms
-->
@@ -26,7 +25,7 @@
-
+