Compare commits

..

No commits in common. "master" and "v0.4.0" have entirely different histories.

55 changed files with 1355 additions and 2907 deletions

View file

@ -1,16 +1,8 @@
# 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](https://nekode.yusur.moe/yusur/vigil).
- Deprecated the old web routes except for `/report` and `/admin`
## 0.4.0 ## 0.4.0
- Added dependency to [SUOU](https://github.com/yusurko/suou) library - Added dependency to [SUOU](https://github.com/sakuragasaki46/suou) library
- Users can now block each other - Users can now block each other
+ Blocking a user prevents them from seeing your comments, posts (standalone or in feed) and profile + 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 - Added user strikes: a strike logs the content of a removed message for future use

View file

@ -11,7 +11,7 @@
* Unix-like OS (Docker container, Linux or MacOS are all good). * Unix-like OS (Docker container, Linux or MacOS are all good).
* **Python** >=3.10. Recommended to use a virtualenv (unless in Docker lol). * **Python** >=3.10. Recommended to use a virtualenv (unless in Docker lol).
* **PostgreSQL** at least 16. * **PostgreSQL** at least 16.
* **Redis**/Valkey (as of 0.5.0 unused in codebase -_-). * **Redis**/Valkey (as of 0.4.0 unused in codebase -_-).
* **Docker** and **Docker Compose**. * **Docker** and **Docker Compose**.
* A server machine with a public IP address and shell access (mandatory for production, optional for development/staging). * 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. * 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. * 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.
* `SERVER_NAME` (see above) * `DOMAIN_NAME` (see above)
* `APP_NAME` * `APP_NAME`
* `DATABASE_URL` (hint: `postgresql://username:password@localhost/dbname`) * `DATABASE_URL` (hint: `postgresql://username:password@localhost/dbname`)
* `SECRET_KEY` (you can generate one with the command `cat /dev/random | tr -dc A-Za-z0-9_. | head -c 56`) * `SECRET_KEY` (you can generate one with the command `cat /dev/random | tr -dc A-Za-z0-9_. | head -c 56`)

View file

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

View file

@ -1,32 +1,30 @@
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 quart import ( from flask import (
Quart, flash, g, jsonify, redirect, render_template, Flask, g, redirect, render_template,
request, send_from_directory, url_for request, send_from_directory, url_for
) )
import os import os
import dotenv import dotenv
from quart_auth import AuthUser, QuartAuth, Action as QA_Action, current_user from flask_login import LoginManager
from quart_wtf import CSRFProtect from flask_wtf.csrf import CSRFProtect
from sqlalchemy import inspect, select from sqlalchemy import select
from suou import Snowflake, ssv_list, yesno from sqlalchemy.exc import SQLAlchemyError
from suou import Snowflake, ssv_list
from werkzeug.routing import BaseConverter from werkzeug.routing import BaseConverter
from suou.sass import SassAsyncMiddleware from sassutils.wsgi import SassMiddleware
from suou.quart import negotiate from werkzeug.middleware.proxy_fix import ProxyFix
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.5.0-dev50' __version__ = '0.4.0'
APP_BASE_DIR = os.path.dirname(os.path.dirname(__file__)) APP_BASE_DIR = os.path.dirname(os.path.dirname(__file__))
@ -37,46 +35,31 @@ 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()
server_name = ConfigValue() domain_name = ConfigValue()
force_server_name = ConfigValue(cast=yesno, default=True)
private_assets = ConfigValue(cast=ssv_list) private_assets = ConfigValue(cast=ssv_list)
app_is_behind_proxy = ConfigValue(cast=int, default=0) 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)
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_')
# v-- deprecated --v
jquery_url = ConfigValue(default='https://ajax.googleapis.com/ajax/libs/jquery/3.7.1/jquery.min.js')
# ^----------------^
app_config = AppConfig() app_config = AppConfig()
logging.basicConfig(level=logging.WARNING) app = Flask(__name__)
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
if app_config.server_name and app_config.force_server_name: from .models import db, User, Post
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 # SASS
app.asgi_app = SassAsyncMiddleware(app.asgi_app, dict( app.wsgi_app = SassMiddleware(app.wsgi_app, dict(
freak=('static/sass', 'static/css', '/static/css', True) 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.asgi_app = ProxyFixMiddleware( app.wsgi_app = ProxyFix(
app.asgi_app, trusted_hops=app_config.app_is_behind_proxy, mode='legacy' app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1
) )
class SlugConverter(BaseConverter): class SlugConverter(BaseConverter):
@ -92,177 +75,100 @@ 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.bind(app_config.database_url) db.init_app(app)
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
async def _inject_variables(): 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__,
'server_name': app_config.server_name, 'domain_name': app_config.domain_name,
'url_for_css': (lambda name, **kwargs: url_for('static', filename=f'css/{name}.css', **kwargs)), '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_count, 'user_count': User.active_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('_', ' ')
} }
@app.before_request @login_manager.user_loader
async def _load_user(): def _inject_user(userid):
try: try:
await current_user._load() u = db.session.execute(select(User).where(User.id == userid)).scalar()
except RuntimeError as e: if u is None or u.is_disabled:
logger.error(f'{e}') return None
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:
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) @app.errorhandler(ProgrammingError)
async def error_db(body): def error_db(body):
g.no_user = True g.no_user = True
logger.error(f'No database access! (url is {redact_url_password(app.config['SQLALCHEMY_DATABASE_URI'])!r})', RuntimeWarning) warnings.warn(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') return render_template('500.html'), 500
@app.errorhandler(400) @app.errorhandler(400)
async def error_400(body): def error_400(body):
return await error_handler_for(400, body, '400.html') return render_template('400.html'), 400
@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)
async def error_403(body): def error_403(body):
return await error_handler_for(403, body, '403.html') return render_template('403.html'), 403
async def find_guild_or_user(name: str) -> str | None: from .search import find_guild_or_user
"""
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)
async def error_404(body): 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 = await find_guild_or_user(mo.group(1)) alternative = 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:
logger.error(f'Exception in find_guild_or_user: {e}') warnings.warn(f'Exception in find_guild_or_user: {e}')
pass pass
if app_config.server_name not in (None, request.host): return render_template('404.html'), 404
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) @app.errorhandler(405)
async def error_405(body): def error_405(body):
return await error_handler_for(405, body, '405.html') return render_template('405.html'), 405
@app.errorhandler(451) @app.errorhandler(451)
async def error_451(body): def error_451(body):
return await error_handler_for(451, body, '451.html') return render_template('451.html'), 451
@app.errorhandler(500) @app.errorhandler(500)
async def error_500(body): def error_500(body):
g.no_user = True g.no_user = True
return await error_handler_for(500, body, '500.html') return render_template('500.html'), 500
@app.route('/favicon.ico') @app.route('/favicon.ico')
async def favicon_ico(): def favicon_ico():
return await send_from_directory(APP_BASE_DIR, 'favicon.ico') return send_from_directory(APP_BASE_DIR, 'favicon.ico')
@app.route('/robots.txt') @app.route('/robots.txt')
async def robots_txt(): def robots_txt():
return await send_from_directory(APP_BASE_DIR, 'robots.txt') return send_from_directory(APP_BASE_DIR, 'robots.txt')
from .website import blueprints from .website import blueprints
@ -272,8 +178,8 @@ for bp in blueprints:
from .ajax import bp from .ajax import bp
app.register_blueprint(bp) app.register_blueprint(bp)
from .rest import bp from .rest import rest_bp
app.register_blueprint(bp) app.register_blueprint(rest_bp)

View file

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

View file

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

View file

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

View file

@ -1,18 +1,16 @@
from quart_auth import current_user from flask_login import current_user
from sqlalchemy import and_, distinct, func, select from sqlalchemy import and_, distinct, func, select
from suou import not_implemented from .models import Comment, Member, db, Post, Guild, User
from .models import Comment, Member, Post, Guild, User
current_user: User
def cuser() -> User: def cuser() -> User:
return current_user.user if current_user else None return current_user if current_user.is_authenticated else None
def cuser_id() -> int: def cuser_id() -> int:
return current_user.id if current_user else None return current_user.id if current_user.is_authenticated else None
def public_timeline(): 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(
@ -20,20 +18,15 @@ 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, Guild.id == Post.topic_id).join(User, User.id == Post.author_id).where( return select(Post).join(Guild).join(User, User.id == Post.author_id).where(
Post.privacy == 0, Guild.name == gname, User.not_suspended(), Post.not_removed(), User.has_not_blocked(Post.author_id, cuser_id()) 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: User): def user_timeline(user_id):
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()), Post.author_id == user.id, User.not_suspended(), Post.not_removed(), User.has_not_blocked(Post.author_id, cuser_id()) Post.visible_by(cuser_id()), User.id == user_id, User.not_suspended(), Post.not_removed(), User.has_not_blocked(Post.author_id, cuser_id())
).order_by(Post.created_at.desc()) ).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(): def top_guilds_query():
q_post_count = func.count(distinct(Post.id)).label('post_count') q_post_count = func.count(distinct(Post.id)).label('post_count')
q_sub_count = func.count(distinct(Member.id)).label('sub_count') q_sub_count = func.count(distinct(Member.id)).label('sub_count')
@ -43,13 +36,6 @@ def top_guilds_query():
.group_by(Guild).having(q_post_count > 5).order_by(q_post_count.desc(), q_sub_count.desc()) .group_by(Guild).having(q_post_count > 5).order_by(q_post_count.desc(), q_sub_count.desc())
return qr return qr
def new_comments(p: Post):
@not_implemented() return select(Comment).where(Comment.parent_post_id == p.id, Comment.parent_comment_id == None,
class Algorithms: Comment.not_removed(), User.has_not_blocked(Comment.author_id, cuser_id())).order_by(Comment.created_at.desc())
"""
Return SQL queries for algorithms.
"""
def __init__(self, me: User | None):
self.me = me

View file

@ -6,7 +6,7 @@ import subprocess
from sqlalchemy import create_engine, select from sqlalchemy import create_engine, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from . import __version__ as version, app_config from . import __version__ as version, app
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
async def main(): 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,19 +26,18 @@ async 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
await db.create_all(engine) db.metadata.create_all(engine)
print('Schema upgraded!') print('Schema upgraded!')
if args.flush: if args.flush:
cnt = 0 cnt = 0
async with db as session: with app.app_context():
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
session.add(u) db.session.add(u)
session.commit() db.session.commit()
print(f'Recomputed karma of {cnt} users') print(f'Recomputed karma of {cnt} users')
print(f'Visit <https://{app_config.server_name}>') print(f'Visit <https://{os.getenv("DOMAIN_NAME")}>')

View file

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

77
freak/dei.py Normal file
View file

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

View file

@ -2,36 +2,28 @@
from __future__ import annotations from __future__ import annotations
import asyncio
from collections import namedtuple from collections import namedtuple
import datetime import datetime
from functools import partial 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 typing import Any, Callable from sqlalchemy import Column, Integer, String, ForeignKey, UniqueConstraint, insert, text, \
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 suou.sqlalchemy_async import SQLAlchemy from flask_sqlalchemy import SQLAlchemy
from suou import SiqType, Snowflake, Wanted, deprecated, makelist, not_implemented, want_isodate from flask_login import AnonymousUserMixin
from suou import SiqType, Snowflake, Wanted, deprecated, not_implemented
from suou.sqlalchemy import create_session, declarative_base, id_column, parent_children, snowflake_column from 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 . import app_config from freak import app_config
from .utils import get_remote_addr from .utils import age_and_days, get_remote_addr, timed_cache
from suou import timed_cache, age_and_days
import logging
logger = logging.getLogger(__name__)
## Constants and enums ## 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
@ -79,16 +71,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 somebody deleted suspended default bot developer undefined null ' 'nobody deleted suspended default bot developer undefined null '
'ai automod clanker automoderator assistant privacy anonymous removed assistance ' 'ai automod 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 lolicon kkk pnf adl cop tranny google trustandsafety safety ice fbi nsa it ' 'loli kkk pnf adl cop tranny google trustandsafety safety ice fbi nsa it '
## VVVVIP ## VVVVIP
'potus realdonaldtrump elonmusk teddysphotos mrbeast jkrowling pewdiepie ' 'potus realdonaldtrump elonmusk teddysphotos mrbeast jkrowling pewdiepie '
'elizabethii elizabeth2 king queen pontifex hogwarts lumos alohomora isis daesh retards ' 'elizabethii king queen pontifex hogwarts lumos alohomora isis daesh '
).split()) ).split())
def username_is_legal(username: str) -> bool: def username_is_legal(username: str) -> bool:
@ -102,26 +94,21 @@ 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.server_name, app_config.secret_key, Base = declarative_base(app_config.domain_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)
## .accounts requires db # the BaseModel() class will be removed in 0.5
#current_user: UserLoader from .iding import new_id
@deprecated('id_column() and explicit id column are better. Will be removed in 0.5')
class BaseModel(Base):
__abstract__ = True
id = Column(BigInteger, primary_key=True, default=new_id)
## Many-to-many relationship keys for some reasons have to go ## Many-to-many relationship keys for some reasons have to go
## BEFORE other table definitions. ## BEFORE other table definitions.
@ -164,7 +151,6 @@ 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'))
@ -185,8 +171,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', lazy='selectin') upvoted_posts = relationship("Post", secondary=PostUpvote, back_populates='upvoters')
#comments = relationship("Comment", back_populates='author', lazy='selectin') #comments = relationship("Comment", back_populates='author')
@property @property
def is_disabled(self): def is_disabled(self):
@ -203,16 +189,13 @@ 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)
@ -223,32 +206,26 @@ 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, *, typed = False): def simple_info(self):
""" """
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?
gg = dict( return 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()
badges = self.badges(), ## TODO add badges?
) )
if typed:
gg['type'] = 'user'
return gg
@deprecated('updates may be not atomic. DO NOT USE until further notice') def reward(self, points=1):
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():
async with db as session: db.session.execute(update(User).where(User.id == self.id).values(karma = self.karma + points))
await session.execute(update(User).where(User.id == self.id).values(karma = self.karma + points)) db.session.commit()
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
@ -263,12 +240,10 @@ class User(Base):
return check_password_hash(self.passhash, password) return check_password_hash(self.passhash, password)
@classmethod @classmethod
@timed_cache(1800, async_=True) @timed_cache(1800)
async def active_count(cls) -> int: def active_count(cls) -> int:
active_th = datetime.datetime.now() - datetime.timedelta(days=30) active_th = datetime.datetime.now() - datetime.timedelta(days=30)
async with db as session: 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()
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}>'
@ -277,25 +252,10 @@ 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())
async def has_blocked(self, other: User | None) -> bool: def has_blocked(self, other: User | None) -> bool:
if not want_User(other, var_name='other', prefix='User.has_blocked()'): if other is None or not other.is_authenticated:
return False return False
async with db as session: return bool(db.session.execute(select(UserBlock).where(UserBlock.c.actor_id == self.id, UserBlock.c.target_id == other.id)).scalar())
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):
@ -308,10 +268,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(session.execute(select(Friendship).where(...)).scalar()) return False #bool(db.session.execute(select(Friendship).where(...)).scalar())
@classmethod @classmethod
def has_not_blocked(cls, actor: int, target: int): def has_not_blocked(cls, actor, target):
""" """
Filter out a content if the author has blocked current user. Returns a query. Filter out a content if the author has blocked current user. Returns a query.
@ -325,34 +285,26 @@ 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
async def recompute_karma(self): def recompute_karma(self):
"""
Recompute karma as of 0.4.0 karma handling
"""
async with db as session:
c = 0 c = 0
c += session.execute(select(func.count('*')).select_from(Post).where(Post.author == self)).scalar() c += db.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 += db.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() c -= db.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? def moderates(self, gu: Guild) -> bool:
@timed_cache(60, async_=True)
async def strike_count(self) -> int:
async with db as session:
return (await session.execute(select(func.count('*')).select_from(UserStrike).where(UserStrike.user_id == self.id))).scalar()
async def moderates(self, gu: Guild) -> bool:
async with db as session:
## owner ## owner
if gu.owner_id == self.id: if gu.owner_id == self.id:
return True return True
## admin or global mod ## admin or global mod
if self.is_administrator: if self.is_administrator:
return True return True
memb = (await session.execute(select(Member).where(Member.user_id == self.id, Member.guild_id == gu.id))).scalar() memb = db.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
@ -360,29 +312,6 @@ class User(Base):
## 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
@ -417,102 +346,64 @@ class Guild(Base):
def handle(self): def handle(self):
return f'+{self.name}' return f'+{self.name}'
async def subscriber_count(self): def subscriber_count(self):
async with db as session: return db.session.execute(select(func.count('*')).select_from(Member).where(Member.guild == self, Member.is_subscribed == True)).scalar()
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, lazy='selectin') owner = relationship(User, foreign_keys=owner_id)
posts = relationship('Post', back_populates='guild', lazy='selectin') posts = relationship('Post', back_populates='guild')
async def post_count(self): def has_subscriber(self, other: User) -> bool:
async with db as session: if other is None or not other.is_authenticated:
return (await session.execute(select(func.count('*')).select_from(Post).where(Post.guild == self))).scalar()
async def has_subscriber(self, other: User) -> bool:
if not want_User(other, var_name='other', prefix='Guild.has_subscriber()'):
return False return False
async with db as session: return bool(db.session.execute(select(Member).where(Member.user_id == other.id, Member.guild_id == self.id, Member.is_subscribed == True)).scalar())
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: def has_exiled(self, other: User) -> bool:
if not want_User(other, var_name='other', prefix='Guild.has_exiled()'): if other is None or not other.is_authenticated:
return False return False
async with db as session: u = db.session.execute(select(Member).where(Member.user_id == other.id, Member.guild_id == self.id)).scalar()
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
async def allows_posting(self, other: User) -> bool: def allows_posting(self, other: User) -> bool:
async with db as session: if self.owner is None:
# control owner_id instead of owner: the latter causes MissingGreenletError
if self.owner_id is None:
return False return False
if other.is_disabled: if other.is_disabled:
return False return False
mem: Member | None = (await session.execute(select(Member).where(Member.user_id == other.id, Member.guild_id == self.id))).scalar() 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: if mem and mem.is_banned:
return False return False
if await other.moderates(self): if other.moderates(self):
return True return True
if self.is_restricted: if self.is_restricted:
return (mem and mem.is_approved) return (mem and mem.is_approved)
return True return True
async def moderators(self):
async with db as session: def moderators(self):
if self.owner_id: if self.owner:
owner = (await session.execute(select(User).where(User.id == self.owner_id))).scalar() yield ModeratorInfo(self.owner, True)
yield ModeratorInfo(owner, True) for mem in db.session.execute(select(Member).where(Member.guild_id == self.id, Member.is_moderator == True)).scalars():
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: if mem.user != self.owner and not mem.is_banned:
yield ModeratorInfo(mem.user, False) yield ModeratorInfo(mem.user, False)
async def update_member(self, u: User | Member, /, **values): def update_member(self, u: User | Member, /, **values):
if isinstance(u, User): if isinstance(u, User):
async with db as session: m = db.session.execute(select(Member).where(Member.user_id == u.id, Member.guild_id == self.id)).scalar()
m = (await session.execute(select(Member).where(Member.user_id == u.id, Member.guild_id == self.id))).scalar()
if m is None: if m is None:
m = (await session.execute(insert(Member).values( m = db.session.execute(insert(Member).values(
guild_id = self.id, guild_id = self.id,
user_id = u.id, user_id = u.id,
**values **values
).returning(Member))).scalar() ).returning(Member)).scalar()
if m is None: if m is None:
raise RuntimeError raise RuntimeError
return m return m
else: else:
m = u m = u
if len(values): if len(values):
async with db as session: db.session.execute(update(Member).where(Member.user_id == u.id, Member.guild_id == self.id).values(**values))
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
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) Topic = deprecated('renamed to Guild')(Guild)
@ -542,9 +433,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, lazy='selectin') user = relationship(User, primaryjoin = lambda: User.id == Member.user_id)
guild = relationship(Guild, lazy='selectin') guild = relationship(Guild)
banned_by = relationship(User, primaryjoin = lambda: User.id == Member.banned_by_id, lazy='selectin') banned_by = relationship(User, primaryjoin = lambda: User.id == Member.banned_by_id)
@property @property
def is_banned(self): def is_banned(self):
@ -583,14 +474,10 @@ class Post(Base):
removed_reason = Column(SmallInteger, nullable=True) removed_reason = Column(SmallInteger, nullable=True)
# utilities # utilities
author: Relationship[User] = relationship("User", foreign_keys=[author_id], lazy='selectin')#, back_populates="posts") author: Relationship[User] = relationship("User", lazy='selectin', foreign_keys=[author_id])#, back_populates="posts")
guild: Relationship[Guild] = relationship("Guild", back_populates="posts", lazy='selectin') guild: Relationship[Guild] = relationship("Guild", back_populates="posts", lazy='selectin')
comments = relationship("Comment", back_populates="parent_post", lazy='selectin') comments = relationship("Comment", back_populates="parent_post")
upvoters = relationship("User", secondary=PostUpvote, back_populates='upvoted_posts', lazy='selectin') upvoters = relationship("User", secondary=PostUpvote, back_populates='upvoted_posts')
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
@ -602,41 +489,33 @@ 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)"
async def upvotes(self) -> int: def upvotes(self) -> int:
async with db as session: return (db.session.execute(select(func.count('*')).select_from(PostUpvote).where(PostUpvote.c.post_id == self.id, PostUpvote.c.is_downvote == False)).scalar()
upv = (await 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())
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
async def upvoted_by(self, user: User | None): def upvoted_by(self, user: User | AnonymousUserMixin | None):
if not want_User(user, var_name='user', prefix='Post.upvoted_by()'): if not user or not user.is_authenticated:
return 0 return 0
async with db as session: v: PostUpvote | None = db.session.execute(select(PostUpvote.c).where(PostUpvote.c.voter_id == user.id, PostUpvote.c.post_id == self.id)).fetchone()
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:
if v is None: if v.is_downvote:
return 0
if v == (True,):
return -1 return -1
if v == (False,):
return 1 return 1
logger.warning(f'unexpected value: {v}')
return 0 return 0
async def top_level_comments(self, limit=None): def top_level_comments(self, limit=None):
async with db as session: return db.session.execute(select(Comment).where(Comment.parent_comment == None, Comment.parent_post == self).order_by(Comment.created_at.desc()).limit(limit)).scalars()
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}'
async def report_count(self) -> int: 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() return db.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, async_=True) @timed_cache(1800)
async def count(cls): def count(cls):
async with db as session: return db.session.execute(select(func.count('*')).select_from(cls)).scalar()
return (await session.execute(select(func.count('*')).select_from(cls))).scalar()
@property @property
def is_removed(self) -> bool: def is_removed(self) -> bool:
@ -648,32 +527,8 @@ 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 == 0) return or_(Post.author_id == user_id, Post.privacy.in_((0, 1)))
#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): class Comment(Base):
__tablename__ = 'freak_comment' __tablename__ = 'freak_comment'
@ -699,31 +554,18 @@ 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], lazy='selectin')#, back_populates='comments') author = relationship('User', foreign_keys=[author_id])#, back_populates='comments')
parent_post: Relationship[Post] = relationship("Post", back_populates="comments", foreign_keys=[parent_post_id], lazy='selectin') parent_post: Relationship[Post] = relationship("Post", back_populates="comments", foreign_keys=[parent_post_id])
parent_comment, child_comments = parent_children('comment', parent_remote_side=Wanted('id')) parent_comment, child_comments = parent_children('comment', parent_remote_side=Wanted('id'))
def url(self): def url(self):
return self.parent_post.url() + f'/comment/{Snowflake(self.id):l}' 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: def report_url(self) -> str:
return f'/report/comment/{Snowflake(self.id):l}' return f'/report/comment/{Snowflake(self.id):l}'
async def report_count(self) -> int: def report_count(self) -> int:
async with db as session: return db.session.execute(select(func.count('*')).select_from(PostReport).where(PostReport.target_id == self.id, ~PostReport.update_status.in_((1, 2)))).scalar()
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:
@ -733,21 +575,6 @@ class Comment(Base):
def not_removed(cls): def not_removed(cls):
return Post.removed_at == None 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): class PostReport(Base):
__tablename__ = 'freak_postreport' __tablename__ = 'freak_postreport'
@ -761,14 +588,13 @@ 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', lazy='selectin') author = relationship('User')
async def target(self): def target(self):
async with db as session:
if self.target_type == REPORT_TARGET_POST: if self.target_type == REPORT_TARGET_POST:
return (await session.execute(select(Post).where(Post.id == self.target_id))).scalar() return db.session.execute(select(Post).where(Post.id == self.target_id)).scalar()
elif self.target_type == REPORT_TARGET_COMMENT: elif self.target_type == REPORT_TARGET_COMMENT:
return (await session.execute(select(Comment).where(Comment.id == self.target_id))).scalar() return db.session.execute(select(Comment).where(Comment.id == self.target_id)).scalar()
else: else:
return self.target_id return self.target_id
@ -790,10 +616,9 @@ 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, lazy='selectin') user = relationship(User, primaryjoin= lambda: User.id == UserStrike.user_id)
issued_by = relationship(User, primaryjoin= lambda: User.id == UserStrike.issued_by_id, lazy='selectin') issued_by = relationship(User, primaryjoin= lambda: User.id == UserStrike.issued_by_id)
# PostUpvote table is at the top !! # PostUpvote table is at the top !!

View file

@ -1,472 +1,68 @@
from __future__ import annotations
import datetime
import sys
from typing import Iterable, TypeVar
import logging
from quart import render_template, session from flask import Blueprint, redirect, url_for
from quart import abort, Blueprint, redirect, request, url_for from flask_restx import Resource
from pydantic import BaseModel, Field from sqlalchemy import select
from quart_auth import current_user, login_required, login_user, logout_user from suou import Snowflake
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 from suou.flask_sqlalchemy import require_auth
auth_required = deprecated('use login_required() and current_user instead')(require_auth(User, db))
@not_implemented() from suou.flask_restx import Api
async def authenticated():
pass
@bp.get('/nurupo') from ..models import Post, User, db
async def get_nurupo():
return dict(ga=-1)
@bp.get('/health') rest_bp = Blueprint('rest', __name__, url_prefix='/v1')
async def health(): rest = Api(rest_bp)
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
)
return hi auth_required = require_auth(User, db)
@bp.get('/oath') @rest.route('/nurupo')
async def oath(): class Nurupo(Resource):
try: def get(self):
## pull csrf token from session return dict(nurupo='ga')
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 ## 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. It was therefore dismissed. ## redirect, neither is able to get user injected.
## Auth-based REST endpoints won't be fully functional until 0.6 in most cases ## Auth-based REST endpoints won't be fully functional until 0.6 in most cases
## USERS ## @rest.route('/user/@me')
class UserInfoMe(Resource):
@auth_required(required=True)
def get(self, user: User):
return redirect(url_for('rest.UserInfo', user.id)), 302
@bp.get('/user/@me') @rest.route('/user/<b32l:id>')
@login_required class UserInfo(Resource):
async def get_user_me(): def get(self, id: int):
return redirect(url_for(f'rest.user_get', id=current_user.id)), 302 ## TODO sanizize REST to make blocked users inaccessible
u: User | None = db.session.execute(select(User).where(User.id == id)).scalar()
def _user_info(u: User): if u is None:
return dict( return dict(error='User not found'), 404
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 = want_isodate(u.joined_at), joined_at = u.joined_at.isoformat('T'),
karma = u.karma, karma = u.karma,
age = u.age(), age = u.age()
biography=u.biography,
badges = u.badges()
) )
@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}) return dict(users={f'{Snowflake(id):l}': uj})
@bp.get('/user/<b32l:id>/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 = [] @rest.route('/post/<b32l:id>')
algo = user_timeline(u) class SinglePost(Resource):
posts = await db.paginate(algo) def get(self, id: int):
async for p in posts: p: Post | None = db.session.execute(select(Post).where(Post.id == id)).scalar()
feed.append(await p.feed_info_counts())
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().simple_info(typed=True), to = p.topic_or_user().handle(),
created_at = p.created_at.isoformat('T') created_at = p.created_at.isoformat('T')
) )
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}) return dict(posts={f'{Snowflake(id):l}': pj})
class VoteIn(BaseModel):
vote: int
@bp.post('/post/<b32l:id>/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/<b32l:id>/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/<b32l:gid>')
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/@<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(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/@<gname>')
@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")
)

View file

@ -2,8 +2,12 @@
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]
@ -23,3 +27,24 @@ class SearchQuery:
return sq return sq
def find_guild_or_user(name: str) -> str | None:
"""
Used in 404 error handler.
Returns an URL to redirect or None for no redirect.
"""
if hasattr(g, 'no_user'):
return None
gu = db.session.execute(select(Guild).where(Guild.name == name)).scalar()
if gu is not None:
flash(f'There is nothing at /{name}. Luckily, a guild with name {gu.handle()} happens to exist. Next time, remember to add + before!')
return gu.url()
user = db.session.execute(select(User).where(User.username == name)).scalar()
if user is not None:
flash(f'There is nothing at /{name}. Luckily, a user named {user.handle()} happens to exist. Next time, remember to add @ before!')
return user.url()
return None

View file

@ -1,652 +0,0 @@
/**
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; } }

View file

@ -9,9 +9,28 @@
{% block content %} {% block content %}
<div class="content"> <div class="content">
{% filter to_markdown %} <h2>Stats</h2>
{% include "about.md" %} <ul>
{% endfilter %} <li>No. of posts: <strong>{{ post_count }}</strong></li>
<li>No. of active users (posters in the last 30 days): <strong>{{ user_count }}</strong></li>
</ul>
<h2>Software versions</h2>
<ul>
<li><strong>Python</strong>: {{ python_version }}</strong></li>
<li><strong>SQLAlchemy</strong>: {{ sa_version }}</li>
<li><strong>Flask</strong>: {{ flask_version }}</li>
<li><strong>{{ app_name }}</strong>: {{ app_version }}</li>
</ul>
<h2>License</h2>
<p>Source code is available at: <a href="https://github.com/yusurko/freak">https://github.com/yusurko/freak</a></p>
{% if impressum %}
<h2>Legal Contacts</h2>
<pre>{{ impressum }}</pre>
{% endif %}
</div> </div>
{% endblock %} {% endblock %}

View file

@ -1,25 +0,0 @@
## 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: <https://nekode.yusur.moe/yusur/freak>
{% if impressum %}
## Legal Contacts
```
{{ impressum }}
```
{% endif %}

View file

@ -1,10 +0,0 @@
{% extends "admin/admin_base.html" %}
{% block content %}
<div class="centered">
<h2>Bad Request</h2>
<p><a href="/">Back to homepage.</a></p>
</div>
{% endblock %}

View file

@ -1,12 +0,0 @@
{% extends "admin/admin_base.html" %}
{% from "macros/title.html" import title_tag with context %}
{% block content %}
<div class="centered">
<h2>Access Denied</h2>
<p><a href="/">Back to homepage.</a></p>
</div>
{% endblock %}

View file

@ -1,12 +0,0 @@
{% extends "admin/admin_base.html" %}
{% from "macros/title.html" import title_tag with context %}
{% block content %}
<div class="centered">
<h2>Not Found</h2>
<p><a href="/admin/">Back</a></p>
</div>
{% endblock %}

View file

@ -1,11 +0,0 @@
{% extends "admin/admin_base.html" %}
{% block content %}
<div class="centered">
<h2>Internal Server Error</h2>
<p>It's on us. <a href="javascript:history.go(0)">Refresh the page</a>.</p>
</div>
{% endblock %}

View file

@ -5,7 +5,7 @@
{{ title_tag("Admin") }} {{ title_tag("Admin") }}
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" type="text/css" href="/admin/style.css"> <link rel="stylesheet" type="text/css" href="/static/css/style.css">
{% for private_style in private_styles %} {% for private_style in private_styles %}
<link rel="stylesheet" href="{{ private_style }}" /> <link rel="stylesheet" href="{{ private_style }}" />
{% endfor %} {% endfor %}

View file

@ -29,6 +29,6 @@
<button type="submit" name="do" value="1" class="primary">Remove</button> <button type="submit" name="do" value="1" class="primary">Remove</button>
<button type="submit" name="do" value="2">Strike</button> <button type="submit" name="do" value="2">Strike</button>
{% endif %} {% endif %}
<button type="submit" name="do" value="3">Put on hold</button> <button type="submit" name="do" value="2">Put on hold</button>
</form> </form>
{% endblock %} {% endblock %}

View file

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

View file

@ -2,6 +2,7 @@
<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 %}
@ -12,7 +13,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://{{ server_name }}/terms More info: https://{{ domain_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 }}" />
@ -25,7 +26,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 and current_user.color_theme %} class="{{ theme_classes(current_user.color_theme) }}"{% endif %}> <body {% if 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">
@ -44,9 +45,9 @@
{% endif %} {% endif %}
{% if g.no_user %} {% if g.no_user %}
<!-- no user --> <!-- no user -->
{% elif current_user %} {% elif current_user.is_authenticated %}
<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.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) 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>
@ -81,7 +82,6 @@
{% 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 %} {% if current_user and current_user.is_authenticated %}
<footer class="mobile-nav mobileonly"> <footer class="mobile-nav mobileonly">
<ul> <ul>
<li><a href="/" title="Homepage">{{ icon('home') }}</a></li> <li><a href="/" title="Homepage">{{ icon('home') }}</a></li>

View file

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

View file

View file

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

View file

@ -1,6 +1,6 @@
{% macro embed_post(p) %} {% macro embed_post(p) %}
<div id="post-{{ p.id | to_b32l }}" class="post-frame" data-endpoint="{{ p.id | to_b32l }}"> <div id="post-{{ p.id | to_b32l }}" class="post-frame" data-endpoint="{{ p.id | to_b32l }}">
<h3 class="message-title"><a href="/={{ p.id | to_b32l }}">{{ p.title }}</a></h3> <h3 class="message-title"><a href="{{ p.url() }}">{{ p.title }}</a></h3>
<div class="message-meta">Posted by <a href="{{ p.author.url() }}">@{{ p.author.username }}</a> <div class="message-meta">Posted by <a href="{{ p.author.url() }}">@{{ p.author.username }}</a>
{% if p.parent_post %} {% if p.parent_post %}
as a comment on <a href="{{ p.parent_post.url() }}">post “{{ p.parent_post.title }}”</a> as a comment on <a href="{{ p.parent_post.url() }}">post “{{ p.parent_post.title }}”</a>

View file

@ -13,8 +13,8 @@
- <time datetime="{{ p.created_at.isoformat('T') }}">{{ p.created_at.strftime('%B %-d, %Y at %H:%M') }}</time> - <time datetime="{{ p.created_at.isoformat('T') }}">{{ p.created_at.strftime('%B %-d, %Y at %H:%M') }}</time>
</div> </div>
<div class="message-stats"> <div class="message-stats">
{{ feed_upvote(p.id, p.upvotes(), p.upvoted_by(current_user.user)) }} {{ feed_upvote(p.id, p.upvotes(), p.upvoted_by(current_user)) }}
{{ comment_count(p.comment_count()) }} {{ comment_count(p.comments | 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_id %} {% if comment.author %}
<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_id == comment.parent_post.author_id %} {% if comment.author and comment.author == comment.parent_post.author %}
<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_id == current_user.id %} {% if comment.author == current_user %}
{# TODO add comment edit link #} {# TODO add comment edit link #}
{% else %} {% else %}
<li><a href="{{ comment.report_url() }}">{{ icon('report') }} Report</a></li> <li><a href="{{ comment.report_url() }}">{{ icon('report') }} Report</a></li>

View file

@ -8,7 +8,7 @@
<ul> <ul>
<li><i class="icon icon-info" style="font-size:inherit"></i> {{ gu.description }}</li> <li><i class="icon icon-info" style="font-size:inherit"></i> {{ gu.description }}</li>
<li> <li>
<strong>{{ gu.post_count() }}</strong> posts - <strong>{{ gu.posts | 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.user)) }} {{ subscribe_button(gu, gu.has_subscriber(current_user)) }}
{% if not gu.owner_id %} {% if not gu.owner %}
<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.user) %} {% elif gu.has_exiled(current_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.user %} {% if user == current_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.user)) }} {{ subscribe_button(user, user.has_subscriber(current_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 }}">+{{ comm }}</a></strong> - <strong>{{ pcnt }}</strong> posts - <strong>{{ scnt }}</strong> subscribers</li> <li><strong><a href="{{ comm.url() }}">{{ comm.handle() }}</a></strong> - <strong>{{ pcnt }}</strong> posts - <strong>{{ scnt }}</strong> subscribers</li>
{% endfor %} {% endfor %}
{% if current_user and current_user.can_create_community() %} {% if current_user and current_user.is_authenticated and current_user.can_create_community() %}
<li>Cant find your community? <a href="/createcommunity">Create a new one.</a></li> <li>Cant find your community? <a href="/createcommunity">Create a new one.</a></li>
{% endif %} {% endif %}
</ul> </ul>

View file

@ -6,7 +6,130 @@
{% block content %} {% block content %}
<div class="content"> <div class="content">
{% filter to_markdown %} {% filter to_markdown %}
{% include "privacy.md" %} # Privacy Policy
This is a non-authoritative copy of the actual Privacy Policy, always updated at <https://yusur.moe/policies/privacy.html>.
This privacy policy explains how we use personal data we collect when you use
this website.
## Who are we
**New Digital Spirit** is a pending-registration limited liability company based in \[REDACTED], Italy. Our website with updated contact information is <https://ndspir.it>.
Contact details: \[REDACTED]
## What are our domains
The New Digital Spirit Network includes these domains (and all relative subdomains):
* sakuragasaki46.net;
* sakux.moe;
* yusur.moe;
* sfio.moe;
* newdigitalspirit.com;
* ndspir.it;
* cittadeldank.it;
* rinascitasentimentale.it;
* ilterrestre.org;
* yusurland.xyz;
* laprimaparola.info;
* faxrizz.xyz;
* lacasadimimiebubu.com;
* strozeromail.com;
* other domains owned for brand protection reasons, with no content and that redirect to the former.
## What data do we collect
All websites in the New Digital Spirit Network collect the following data, as a part of automatic and intentional logging:
* **IP Addresses and User Agent Strings**.
Additionally, all sites where login is allowed collect the following data:
* **Session Cookies** - used for login
* **E-mail Addresses** - stored for password resets
* **Dates of Birth** - for legal compliance and terms enforcing reasons
* **User-Generated Content** - of various nature, provided by the user. The user is accountable for all of the data they upload, including sensitive information.
## Our use of cookies
We currently use transactional cookies for the purpose of staying logged in. If you disable those cookies, you will not be able to log in.
No advertising cookies are being currently used on the New Digital Spirit Network.
Websites on the network may additionally set a tracking cookie, for the purpose of
attack prevention ("legitimate interest"). These cookies are set for logged out users and may not be opted out.
## How do we collect your data
The data collected is provided as a part of automated logging, or
explicitly logged when accessing determined resources (in that case, a
warning is usually put when accessing the resource), included but not limited
to the use of tracking pixels.
## How will we use your data
The stated data is collected for various reasons, including law compliance, attack prevention and providing the service.
We take privacy, be it ours or the one of our users, very seriously.
We see leaks of private content (including chats) or data breach, be it in our public spaces or elsewhere,
as a betrayal of our trust and the trust of our users, other than a crime and a breach of NDA.
We'll close an eye ONLY when we happen to receive a valid subpoena from an accredited authority,
and we are forced to comply at gunpoint or under threat of legal consequences.
## How do we store your data
The data collected is stored securely in EU servers. However,
[our hosting provider](https://www.keliweb.it/) may have random access to the data we collect.
IPs and user agents logged explicitly are deleted after about 3 years.
## What are your data protection rights
* **Right to access** - You have the right to request New Digital Spirit for copies
of your personal data.
* **Right to rectification** - You have the right to request that
New Digital Spirit correct or complete any information you believe is not
accurate or incomplete.
* **Right to erasure** - You have the right to request that New Digital Spirit
erase your personal data, under certain condition.
* **Right to restrict processing** - You have the right to request that
New Digital Spirit restrict the processing of your personal data, under certain
conditions.
* **Right to object to processing** - You have the right to object to
New Digital Spirits processing of your personal data, under certain conditions.
* **Right to data portability** - You have the right to request that
New Digital Spirit transfer the data that we have collected to another
organization, or directly to you, under certain conditions.
If you make a request, we have one (1) month to respond to you.
If you would like to exercise any of these rights, please contact us at our
email: \[REDACTED]
## Minimum age
We do not knowingly collect data from users under the age of 13, or United States residents under the age of 18.
Data knowingly from accounts belonging to underage users will be deleted, and their accounts will be terminated.
## Cookies
Cookies are text files placed on your computer to collect standard Internet
log information and visitor behavior information. When you visit our websites,
we may collect information from you automatically throught cookies or similar technology.
For further information, visit [allaboutcookies.org](https://allaboutcookies.org)
## Privacy policies of other websites
This privacy policy applies exclusively to the websites of the New Digital Spirit Network. Other
websites and subdomains have different privacy policies you should read.
## Updates
Last updated on May 13, 2025.
{% endfilter %} {% endfilter %}
</div> </div>

View file

@ -1,124 +0,0 @@
# Privacy Policy
This is a non-authoritative copy of the actual Privacy Policy, always updated at <https://yusur.moe/policies/privacy.html>.
This privacy policy explains how we use personal data we collect when you use
this website.
## Who are we
**New Digital Spirit** is a pending-registration limited liability company based in \[REDACTED], Italy. Our website with updated contact information is <https://ndspir.it>.
Contact details: \[REDACTED]
## What are our domains
The New Digital Spirit Network includes these domains (and all relative subdomains):
* sakuragasaki46.net;
* sakux.moe;
* yusur.moe;
* sfio.moe;
* newdigitalspirit.com;
* ndspir.it;
* cittadeldank.it;
* rinascitasentimentale.it;
* ilterrestre.org;
* yusurland.xyz;
* laprimaparola.info;
* faxrizz.xyz;
* lacasadimimiebubu.com;
* strozeromail.com;
* other domains owned for brand protection reasons, with no content and that redirect to the former.
## What data do we collect
All websites in the New Digital Spirit Network collect the following data, as a part of automatic and intentional logging:
* **IP Addresses and User Agent Strings**.
Additionally, all sites where login is allowed collect the following data:
* **Session Cookies** - used for login
* **E-mail Addresses** - stored for password resets
* **Dates of Birth** - for legal compliance and terms enforcing reasons
* **User-Generated Content** - of various nature, provided by the user. The user is accountable for all of the data they upload, including sensitive information.
## Our use of cookies
We currently use transactional cookies for the purpose of staying logged in. If you disable those cookies, you will not be able to log in.
No advertising cookies are being currently used on the New Digital Spirit Network.
Websites on the network may additionally set a tracking cookie, for the purpose of
attack prevention ("legitimate interest"). These cookies are set for logged out users and may not be opted out.
## How do we collect your data
The data collected is provided as a part of automated logging, or
explicitly logged when accessing determined resources (in that case, a
warning is usually put when accessing the resource), included but not limited
to the use of tracking pixels.
## How will we use your data
The stated data is collected for various reasons, including law compliance, attack prevention and providing the service.
We take privacy, be it ours or the one of our users, very seriously.
We see leaks of private content (including chats) or data breach, be it in our public spaces or elsewhere,
as a betrayal of our trust and the trust of our users, other than a crime and a breach of NDA.
We'll close an eye ONLY when we happen to receive a valid subpoena from an accredited authority,
and we are forced to comply at gunpoint or under threat of legal consequences.
## How do we store your data
The data collected is stored securely in EU servers. However,
[our hosting provider](https://www.keliweb.it/) may have random access to the data we collect.
IPs and user agents logged explicitly are deleted after about 3 years.
## What are your data protection rights
* **Right to access** - You have the right to request New Digital Spirit for copies
of your personal data.
* **Right to rectification** - You have the right to request that
New Digital Spirit correct or complete any information you believe is not
accurate or incomplete.
* **Right to erasure** - You have the right to request that New Digital Spirit
erase your personal data, under certain condition.
* **Right to restrict processing** - You have the right to request that
New Digital Spirit restrict the processing of your personal data, under certain
conditions.
* **Right to object to processing** - You have the right to object to
New Digital Spirits processing of your personal data, under certain conditions.
* **Right to data portability** - You have the right to request that
New Digital Spirit transfer the data that we have collected to another
organization, or directly to you, under certain conditions.
If you make a request, we have one (1) month to respond to you.
If you would like to exercise any of these rights, please contact us at our
email: \[REDACTED]
## Minimum age
We do not knowingly collect data from users under the age of 13, or United States residents under the age of 18.
Data knowingly from accounts belonging to underage users will be deleted, and their accounts will be terminated.
## Cookies
Cookies are text files placed on your computer to collect standard Internet
log information and visitor behavior information. When you visit our websites,
we may collect information from you automatically throught cookies or similar technology.
For further information, visit [allaboutcookies.org](https://allaboutcookies.org)
## Privacy policies of other websites
This privacy policy applies exclusively to the websites of the New Digital Spirit Network. Other
websites and subdomains have different privacy policies you should read.
## Updates
Last updated on May 13, 2025.

View file

@ -13,9 +13,6 @@
<a href="?reason={{ opt.code |urlencode }}">{{ opt.description }}</a> <a href="?reason={{ opt.code |urlencode }}">{{ opt.description }}</a>
</li> </li>
{% endfor %} {% endfor %}
<li class="faint">
<a href="{{ back_to_url or 'javascript:history.go(-1);' }}">I clicked "Report" by mistake</a>
</li>
</ul> </ul>
{% endblock %} {% endblock %}

View file

@ -13,9 +13,6 @@
<a href="?reason={{ opt.code |urlencode }}">{{ opt.description }}</a> <a href="?reason={{ opt.code |urlencode }}">{{ opt.description }}</a>
</li> </li>
{% endfor %} {% endfor %}
<li class="faint">
<a href="{{ back_to_url or 'javascript:history.go(-1);' }}">I clicked "Report" by mistake</a>
</li>
</ul> </ul>
{% endblock %} {% endblock %}

View file

@ -6,7 +6,189 @@
{% block content %} {% block content %}
<div class="content"> <div class="content">
{% filter to_markdown %} {% filter to_markdown %}
{% include "rules.md" %} # Community Guidelines
This is a non-authoritative copy of the New Digital Spirit General Regulation, always updated at <https://ndspir.it/rules>.
Every place has rules.
Rules define how people must behave in order to preserve the place's integrity, and are expressions of the will of whoever rules over the place. Usually, part of the rules include basic safety directives and other stuff to make people stay.
You may not participate in our spaces, except in accordance with the rules.
_Last updated: May 5, 2025_
## 1. Remember the human
Empathy, respect and mutual understanding are at the base of any lasting relationship.
Keep a positive influence, and contribute to improving our community and keeping it safe.
Any form of harassment, violence, bullying, credible threats, bigotry, discrimination, hate speech or dehumanizing is not welcome in the spaces of New Digital Spirit.
## 2. Keep it legal
Follow all applicable law (specifically, Italian law and the law in force on the platform), and the Terms of Service of the platform.
> We are not reporting here the law as a whole.
> You can find out more about Italian law on these sites:
> - [Normattiva](https://www.normattiva.it/)
> - [Gazzetta Ufficiale](https://www.gazzettaufficiale.it/)
> - [Brocardi](https://www.brocardi.it/)
>
> Your interpretation of the laws is **at your own risk**; when in doubt, **contact your lawyer**.
>
> Here is a list of most severe crimes in (nearly) all countries:
> - **Child pornography** ( )
> - **Terrorism**
> - **Piracy**/**Copyright infringement**, including downloading, hosting or torrenting copyrighted content (see also rule 10)
> - **Human trafficking**
> - **Sale of drugs** and other regulated goods
> - **Sale of firearms** and other weapons
> - **Murder**
> - **Turning against law enforcement** such as police, including violence, threats, deceit or refusal to comply with orders or identifying oneself
> - **Adultery**/**Rape** - the former in underdeveloped countries, the latter in developed ones
## 3. Don't turn against us
If you have trouble with us, discuss it first with the staff.
Do not put us in trouble by any means, including legal actions or threats, raiding, shitstorming, false accusations, morality trolling, intellectual property violation, and any other act in bad faith against us.
Severe violations of this kind will be met with an unappealable permanent ban.
> You agree to _indemnify_ and _hold harmless_ us, remember.
## 4. Don't turn against other people
Respect other members' privacy and dignity, and make them feel safe all the time.
Inform yourself about consent and boundaries in advance, respect them, and do not engage in stalking or intimidatory conduct. Do not share personally identifiable information (PII) — such as real names, credit card numbers, SSNs, phone numbers, home or work addresses, and face pics. Do not trigger other people's feelings on purpose (i.e. flame or troll).
If you are being blocked, leave them alone and move on.
## 5. Don't break our spaces
Other people have the right to enjoy our spaces in safety.
Do not attempt any form of privilege escalation or disruption.
Do not manipulate the staff or other users.
Do not attempt infrastructural damage, such as security exploits, (D)DoS, nukes, account grabbing, automated raids, social engineering, spamming and flooding. Don't exploit anyone physically or psychologically.
## 6. Enjoy your stay
Nobody is allowed to sell or advertise any product, service or social media channel in our spaces without the staff's authorization.
Always ask other members, before sending them direct messages (DM), if they are okay with it.
Porn stuff (e.g. OnlyFans), sexting/catcalling and financial scams are NEVER welcome.
Do not steal members from our community.
## 7. Stay on topic
Label appropriately any content.
Mark any spoiler and content (i.e. CW) that may hurt someone else's sensibility.
Keep the conversation on topic, and don't attempt to hijack the conversation or go off-topic.
Respect channel specific rules: NSFW and gore are prohibited unless explicitly allowed in the channel or server.
You are encouraged to use tone tags in ambiguous situations.
Avoid speaking or writing in languages the staff or other members can't understand and moderate.
Limited discussions in those languages is allowed as long as an accurate translation is provided along.
Excessive jargon or argot (such as TikTok brainrot) is generally not allowed.
## 8. Be yourself
You are allowed to remain pseudonymous, and use the nickname or pfp that better fits you.
However, you may not impersonate other users or famous people, use blank or misleading usernames, or pretend to be a mod or admin.
Do not post content you don't own without credits or attribution.
Lying about own age is strictly forbidden.
## 9. Be sincere
Keep our spaces authentic and trusted.
Don't spread misinformation.
Fact-check any claim, especially when sensationalistic or newsworthy, before sending or sharing it.
Do not foster conspiracy theories or pseudoscience.
Do not tell lies in order to deceive the staff or fellow members.
Always disclose usage of AI; bots posing as humans are strictly not tolerated.
## 10. What happens here, remains here
Except otherwise noted, anything sent in here is copyrighted.
Use outside our spaces of any conversation without authorization is forbidden, including in court and to train AI models.
Do not leak contents of private channels into public ones or elsewhere, or you'll lose access to our spaces as a whole.
We take leaks of private chats (be it on public channels of ours or other media) very seriously.
It is betrayal of our trust and the trust of our users, other than a crime and a breach of NDA, and it is grounds for terminating your account.
(We'll close an eye ONLY when we happen to receive a valid subpoena from an accredited authority, and we are forced to comply at gunpoint or under threat of legal consequences.) [Learn more…](javascript:void(0);)
> In legalese, you grant us a _non-exclusive, non-transferable, sublicensable, worldwide_ license to use your message content for the purpose of displaying it to other users, and allowing them to interact with you.
>
> You are prohibited from using public and private conversations:
>
> - in court, or as evidence to back rule 3 violations;
> - to train AI (LLM, GPT, ...) models;
> - as part of an investigation for the purpose of legal prosecution;
> - for targeted advertising profilation;
> - in a way that infringes upon applicable copyrights.
## 11. Behave your age
Be mature, and don't engage in immature behavior or lose control of yourself.
Do not gain access to age-restricted channels and spaces if you are not old enough (i.e. you can't access adult-only/NSFW channels while under 18).
In behaviors where age makes a difference, state clearly your age, and get to know the age of others.
**Lying about own age is strictly forbidden.**
You may not engage in any sexual activity (including flirting, sexual roleplay and suggestive behavior) if you are under 18, the other person is not consentient, or outside adult-only spaces, in presence of any minor.
You have the duty to recognize whether someone is trolling you sexually ("jailbait"), and firmly refuse to engage with such behavior.
**Zero tolerance for adults hitting on minors («pedophilia»)**; see our [statement on CSAM and Minor Account Policy](https://sakux.moe/policies/u18.html)
## 12. Keep your stuff to yourself
Do not bring unnecessary drama to our community.
Do not spill your emotions or project your issues all over us.
**We are not your army**. Do not engage in or ask us to engage in "wars" or feuds.
Do not ask us to do things (be them good or bad) for you, for free.
If you want us to do something, you have to pay us.
And we still have the right to refuse to do it.
Do not blame us for things out of our control, we are not responsible for that.
## 13. Take accountability for your actions
Every action has a consequence.
If you break the rules, expect punishment or decay of privileges.
Your punishment is applied to every account you own alike.
Once you are banned, you are banned forever.
You may not use alts to get around moderation decisions or return after being banned.
> Warns and time-outs are final.
>
> At administration's discretion, you may be able to appeal your permanent ban, or pay a small fee to get unbanned. You may submit only one appeal (regardless of it being granted or denied) or pay only one unban fee every 30 days. Permanent bans may be appealed only 3 months after the issue date, or later. Permanent bans for rule 3 (putting us at risk) violations, or for breaking the law, can NEVER be appealed.
>
> We don't care if you get banned from the platform.
>
> Do not use modded clients for illegal purposes, invasion of privacy or ban circumvention.
>
> We reserve the right to ban on sight users and IP addresses we deem highly dangerous for the safety of our community. Remember: **belonging to our community is a privilege, not a right**.
## 14. Staff has the last words
Admins and moderators are the ones in charge of building our community and keeping it clean.
It's not their job, they do it on their free time and they are not paid or rewarded for this.
Therefore, be kind and respectful with them. Staff decisions are final.
You may not ask for moderation permissions or server transfers.
If the staff is breaking the rules and/or making you feel unsafe, report them to me.
I'll take charge and hold them accountable.
## 15. Follow channel-specific rules
Every community and channel is free to define additional rules to their fitness, and its members must abide by them, in addition to global rules and the law.
Channel rules that go against global rules cannot be set.
If you feel unsafe in a community, or feel like your actions and/or presence makes someone else uncomfortable, leave it.
Nobody needs to belong to every community.
## Final words
The updated ruleset is always available at [https://ndspir.it/rules](https://ndspir.it/rules).
In case of conflicts or discrepancies between translations, the English version takes precedence.
The entire text of our General Regulation is free for everyone to use, as long as the text and its core concepts are not altered in a significant way. We encourage its adoption in order to make rules more clear, respecting them more mindless, and moderation easier.
{% endfilter %} {% endfilter %}
</div> </div>
{% endblock %} {% endblock %}

View file

@ -1,183 +0,0 @@
# Community Guidelines
This is a non-authoritative copy of the New Digital Spirit General Regulation, always updated at <https://ndspir.it/rules>.
Every place has rules.
Rules define how people must behave in order to preserve the place's integrity, and are expressions of the will of whoever rules over the place. Usually, part of the rules include basic safety directives and other stuff to make people stay.
You may not participate in our spaces, except in accordance with the rules.
_Last updated: May 5, 2025_
## 1. Remember the human
Empathy, respect and mutual understanding are at the base of any lasting relationship.
Keep a positive influence, and contribute to improving our community and keeping it safe.
Any form of harassment, violence, bullying, credible threats, bigotry, discrimination, hate speech or dehumanizing is not welcome in the spaces of New Digital Spirit.
## 2. Keep it legal
Follow all applicable law (specifically, Italian law and the law in force on the platform), and the Terms of Service of the platform.
> We are not reporting here the law as a whole.
> You can find out more about Italian law on these sites:
> - [Normattiva](https://www.normattiva.it/)
> - [Gazzetta Ufficiale](https://www.gazzettaufficiale.it/)
> - [Brocardi](https://www.brocardi.it/)
>
> Your interpretation of the laws is **at your own risk**; when in doubt, **contact your lawyer**.
>
> Here is a list of most severe crimes in (nearly) all countries:
> - **Child pornography** ( )
> - **Terrorism**
> - **Piracy**/**Copyright infringement**, including downloading, hosting or torrenting copyrighted content (see also rule 10)
> - **Human trafficking**
> - **Sale of drugs** and other regulated goods
> - **Sale of firearms** and other weapons
> - **Murder**
> - **Turning against law enforcement** such as police, including violence, threats, deceit or refusal to comply with orders or identifying oneself
> - **Adultery**/**Rape** - the former in underdeveloped countries, the latter in developed ones
## 3. Don't turn against us
If you have trouble with us, discuss it first with the staff.
Do not put us in trouble by any means, including legal actions or threats, raiding, shitstorming, false accusations, morality trolling, intellectual property violation, and any other act in bad faith against us.
Severe violations of this kind will be met with an unappealable permanent ban.
> You agree to _indemnify_ and _hold harmless_ us, remember.
## 4. Don't turn against other people
Respect other members' privacy and dignity, and make them feel safe all the time.
Inform yourself about consent and boundaries in advance, respect them, and do not engage in stalking or intimidatory conduct. Do not share personally identifiable information (PII) — such as real names, credit card numbers, SSNs, phone numbers, home or work addresses, and face pics. Do not trigger other people's feelings on purpose (i.e. flame or troll).
If you are being blocked, leave them alone and move on.
## 5. Don't break our spaces
Other people have the right to enjoy our spaces in safety.
Do not attempt any form of privilege escalation or disruption.
Do not manipulate the staff or other users.
Do not attempt infrastructural damage, such as security exploits, (D)DoS, nukes, account grabbing, automated raids, social engineering, spamming and flooding. Don't exploit anyone physically or psychologically.
## 6. Enjoy your stay
Nobody is allowed to sell or advertise any product, service or social media channel in our spaces without the staff's authorization.
Always ask other members, before sending them direct messages (DM), if they are okay with it.
Porn stuff (e.g. OnlyFans), sexting/catcalling and financial scams are NEVER welcome.
Do not steal members from our community.
## 7. Stay on topic
Label appropriately any content.
Mark any spoiler and content (i.e. CW) that may hurt someone else's sensibility.
Keep the conversation on topic, and don't attempt to hijack the conversation or go off-topic.
Respect channel specific rules: NSFW and gore are prohibited unless explicitly allowed in the channel or server.
You are encouraged to use tone tags in ambiguous situations.
Avoid speaking or writing in languages the staff or other members can't understand and moderate.
Limited discussions in those languages is allowed as long as an accurate translation is provided along.
Excessive jargon or argot (such as TikTok brainrot) is generally not allowed.
## 8. Be yourself
You are allowed to remain pseudonymous, and use the nickname or pfp that better fits you.
However, you may not impersonate other users or famous people, use blank or misleading usernames, or pretend to be a mod or admin.
Do not post content you don't own without credits or attribution.
Lying about own age is strictly forbidden.
## 9. Be sincere
Keep our spaces authentic and trusted.
Don't spread misinformation.
Fact-check any claim, especially when sensationalistic or newsworthy, before sending or sharing it.
Do not foster conspiracy theories or pseudoscience.
Do not tell lies in order to deceive the staff or fellow members.
Always disclose usage of AI; bots posing as humans are strictly not tolerated.
## 10. What happens here, remains here
Except otherwise noted, anything sent in here is copyrighted.
Use outside our spaces of any conversation without authorization is forbidden, including in court and to train AI models.
Do not leak contents of private channels into public ones or elsewhere, or you'll lose access to our spaces as a whole.
We take leaks of private chats (be it on public channels of ours or other media) very seriously.
It is betrayal of our trust and the trust of our users, other than a crime and a breach of NDA, and it is grounds for terminating your account.
(We'll close an eye ONLY when we happen to receive a valid subpoena from an accredited authority, and we are forced to comply at gunpoint or under threat of legal consequences.) [Learn more…](javascript:void(0);)
> In legalese, you grant us a _non-exclusive, non-transferable, sublicensable, worldwide_ license to use your message content for the purpose of displaying it to other users, and allowing them to interact with you.
>
> You are prohibited from using public and private conversations:
>
> - in court, or as evidence to back rule 3 violations;
> - to train AI (LLM, GPT, ...) models;
> - as part of an investigation for the purpose of legal prosecution;
> - for targeted advertising profilation;
> - in a way that infringes upon applicable copyrights.
## 11. Behave your age
Be mature, and don't engage in immature behavior or lose control of yourself.
Do not gain access to age-restricted channels and spaces if you are not old enough (i.e. you can't access adult-only/NSFW channels while under 18).
In behaviors where age makes a difference, state clearly your age, and get to know the age of others.
**Lying about own age is strictly forbidden.**
You may not engage in any sexual activity (including flirting, sexual roleplay and suggestive behavior) if you are under 18, the other person is not consentient, or outside adult-only spaces, in presence of any minor.
You have the duty to recognize whether someone is trolling you sexually ("jailbait"), and firmly refuse to engage with such behavior.
**Zero tolerance for adults hitting on minors («pedophilia»)**; see our [statement on CSAM and Minor Account Policy](https://sakux.moe/policies/u18.html)
## 12. Keep your stuff to yourself
Do not bring unnecessary drama to our community.
Do not spill your emotions or project your issues all over us.
**We are not your army**. Do not engage in or ask us to engage in "wars" or feuds.
Do not ask us to do things (be them good or bad) for you, for free.
If you want us to do something, you have to pay us.
And we still have the right to refuse to do it.
Do not blame us for things out of our control, we are not responsible for that.
## 13. Take accountability for your actions
Every action has a consequence.
If you break the rules, expect punishment or decay of privileges.
Your punishment is applied to every account you own alike.
Once you are banned, you are banned forever.
You may not use alts to get around moderation decisions or return after being banned.
> Warns and time-outs are final.
>
> At administration's discretion, you may be able to appeal your permanent ban, or pay a small fee to get unbanned. You may submit only one appeal (regardless of it being granted or denied) or pay only one unban fee every 30 days. Permanent bans may be appealed only 3 months after the issue date, or later. Permanent bans for rule 3 (putting us at risk) violations, or for breaking the law, can NEVER be appealed.
>
> We don't care if you get banned from the platform.
>
> Do not use modded clients for illegal purposes, invasion of privacy or ban circumvention.
>
> We reserve the right to ban on sight users and IP addresses we deem highly dangerous for the safety of our community. Remember: **belonging to our community is a privilege, not a right**.
## 14. Staff has the last words
Admins and moderators are the ones in charge of building our community and keeping it clean.
It's not their job, they do it on their free time and they are not paid or rewarded for this.
Therefore, be kind and respectful with them. Staff decisions are final.
You may not ask for moderation permissions or server transfers.
If the staff is breaking the rules and/or making you feel unsafe, report them to me.
I'll take charge and hold them accountable.
## 15. Follow channel-specific rules
Every community and channel is free to define additional rules to their fitness, and its members must abide by them, in addition to global rules and the law.
Channel rules that go against global rules cannot be set.
If you feel unsafe in a community, or feel like your actions and/or presence makes someone else uncomfortable, leave it.
Nobody needs to belong to every community.
## Final words
The updated ruleset is always available at [https://ndspir.it/rules](https://ndspir.it/rules).
In case of conflicts or discrepancies between translations, the English version takes precedence.
The entire text of our General Regulation is free for everyone to use, as long as the text and its core concepts are not altered in a significant way. We encourage its adoption in order to make rules more clear, respecting them more mindless, and moderation easier.

View file

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

View file

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

View file

@ -5,9 +5,111 @@
{% 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 %}
{% include "terms.md" %} # Terms of Service
This is a non-authoritative copy of the actual Terms, always updated at <https://yusur.moe/policies/terms.html>.
The following documents are incorporated into these Terms by reference
(i.e. an extension to these Terms in force):
* [Privacy Policy](/privacy)
* [Community Guidelines](/rules)
* [User Generated Content Terms](https://yusur.moe/policies/ugc.html) on newdigitalspirit.com
* [Minors' Account Policy](https://yusur.moe/policies/u18.html) on newdigitalspirit.com
## Scope and Definition
These terms of service ("Terms") are between **New Digital Spirit**, i.e. its CEO **Sakuragasaki46**, and You,
regarding Your use of all sites and services belonging to New Digital Spirit ("New Digital Spirit Network" / "the Services"),
listed in detail in [Privacy Policy](/policies/privacy.html).
Other websites are not covered by these Terms.
## Age
The whole of New Digital Spirit Network is PG-13. You may not use the Services if you are younger than 13 years old.
Additionally, you may not directly contact New Digital Spirit if you are younger than 18 years old, for any reason besides
privacy-related requests. Any contact request knowingly from people younger than 18 will be ignored.
United States resident under the age of 18 are **not allowed** in any way to access our network without logging in.
New Digital Spirit reserves the right to require ID verification in case of age doubt or potential security threat.
Minors on New Digital Spirit Network are additionally bound to the [Minor Account Policy](/policies/u18.html),
incorporated here by reference.
Systems and plurals are considered to be minors, no matter their body age.
## Intellectual property
Except otherwise noted, the entirety of the content on the New Digital Spirit Network
is intellectual property of Sakuragasaki46 and New Digital Spirit. All rights reserved.
You may not copy, modify, redistribute, mirror the contents of or create alternative Service to
yusur.moe or any other of the Services, or portions thereof, without New Digital Spirit's
prior written permission.
## Privacy Rights
You may not disclose any personally identifiable information (PII) in your possession
that is related to Sakuragasaki46's online persona and that may lead to Sakuragasaki46's
identification or damages to Sakuragasaki46's private life.
Disclosure will be legally regarded as a violation of privacy and a breach of
non-disclosure agreement (NDA), and will be acted upon accordingly, regardless of
the infringer's age or any other legal protection, included but not limited to
termination of the infringer,s accounts.
## IP Loggers
Some sections of the New Digital Spirit Network log IP addresses.
You agree to be logged for security and attack prevention reasons, on the basis of
legitimate interest. Logged information contains user agent strings as well.
## User Generated Content
Some of our Services allow user generated content. By using them, you agree to be bound
to the [User Generated Content Terms](/policies/ugc.html), incorporated here by reference.
## No Warranty
**Except as represented in this agreement, the New Digital Spirit Network
is provided “AS IS”. Other than as provided in this agreement,
New Digital Spirit makes no other warranties, express or implied, and hereby
disclaims all implied warranties, including any warranty of merchantability
and warranty of fitness for a particular purpose.**
## Liability
Sakuragasaki46 or New Digital Spirit **shall not be accountable** for Your damages arising from Your use
of the New Digital Spirit Network.
## Indemnify
You agree to [indemnify and hold harmless](https://www.upcounsel.com/difference-between-indemnify-and-hold-harmless)
Sakuragasaki46 and New Digital Spirit from any and all claims, damages, liabilities, costs and expenses, including reasonable and unreasonable
counsel and attorneys fees, arising out of any breach of this agreement.
## Severability
If any of these Terms (including other Terms incorporated here by reference) shall turn out to be unenforceable,
according to the governing law, the remainder of these Terms shall remain in place.
## Governing Law
These terms of services are governed by, and shall be interpreted in accordance
with, the laws of Italy. You consent to the sole jurisdiction of \[REDACTED], Italy
for all disputes between You and , and You consent to the sole
application of Italian law and European Union law for all such disputes.
## Updates
Last updated on May 13, 2025.
{% endfilter %} {% endfilter %}
</div> </div>
{% endblock %} {% endblock %}

View file

@ -1,105 +0,0 @@
# Terms of Service
This is a non-authoritative copy of the actual Terms, always updated at <https://ndspir.it/terms.html>.
The following documents are incorporated into these Terms by reference
(i.e. an extension to these Terms in force):
* [Privacy Policy](/privacy)
* [Community Guidelines](/rules)
* [User Generated Content Terms](https://ndspir.it/ugc.html) on newdigitalspirit.com
* [Minors' Account Policy](https://ndspir.it/u18.html) on newdigitalspirit.com
## Scope and Definition
These terms of service ("Terms") are between **{{ app_name }}** and You,
regarding Your use of all sites and services belonging to New Digital Spirit ("New Digital Spirit Network" / "the Services"),
listed in detail in [Privacy Policy](/policies/privacy.html).
Other websites are not covered by these Terms.
## Age
The whole of {{ app_name }} is PG-13. You may not use the Services if you are younger than 13 years old.
Additionally, you may not directly contact {{ app_name }} if you are younger than 18 years old, for any reason besides
privacy-related requests. Any contact request knowingly from people younger than 18 will be ignored.
United States resident under the age of 18 are **not allowed** in any way to access our network without logging in.
Australian and Danish users under the age of 16 are not authorized to use their accounts.
New Digital Spirit reserves the right to require ID verification in case of age doubt or suspected security threat.
Minors on New Digital Spirit Network are additionally bound to the [Minor Account Policy](https://ndspir.it/u18.html),
incorporated here by reference.
Systems and plurals are considered to be minors, no matter their body age.
## Intellectual property
Except otherwise noted, the entirety of the content on {{ app_name }}
is intellectual property of {{ app_name }}. All rights reserved.
You may not copy, modify, redistribute, mirror the contents of or create alternative Service to
{{ server_name }} or any other of the Services, or portions thereof, without {{ app_name }}'s
prior written permission.
## Privacy Rights
You may not disclose any personally identifiable information (PII) in your possession
that is related to Sakuragasaki46's online persona and that may lead to Sakuragasaki46's
identification or damages to Sakuragasaki46's private life.
Disclosure will be legally regarded as a violation of privacy and a breach of
non-disclosure agreement (NDA), and will be acted upon accordingly, regardless of
the infringer's age or any other legal protection, included but not limited to
termination of the infringer's accounts.
## IP Loggers
Some sections of the New Digital Spirit Network log IP addresses.
You agree to be logged for security and attack prevention reasons, on the basis of
legitimate interest. Logged information contains user agent strings as well.
## User Generated Content
Some of our Services allow user generated content. By using them, you agree to be bound
to the [User Generated Content Terms](https://ndspir.it/ugc.html), incorporated here by reference.
## No Warranty
**Except as represented in this agreement, {{ app_name }}
is provided “AS IS”. Other than as provided in this agreement,
New Digital Spirit makes no other warranties, express or implied, and hereby
disclaims all implied warranties, including any warranty of merchantability
and warranty of fitness for a particular purpose.**
## Liability
{{ app_name }} **shall not be accountable** for Your damages arising from Your use
of the New Digital Spirit Network.
## Indemnify
You agree to [indemnify and hold harmless](https://www.upcounsel.com/difference-between-indemnify-and-hold-harmless)
{{ app_name }} from any and all claims, damages, liabilities, costs and expenses, including reasonable and unreasonable
counsel and attorneys fees, arising out of any breach of this agreement.
## Severability
If any of these Terms (including other Terms incorporated here by reference) shall turn out to be unenforceable,
according to the governing law, the remainder of these Terms shall remain in place.
## Governing Law
These terms of services are governed by, and shall be interpreted in accordance
with, the laws of Italy. You consent to the sole jurisdiction of \[REDACTED], Italy
for all disputes between You and {{ app_name }}, and You consent to the sole
application of Italian law and European Union law for all such disputes.
## Updates
Last updated on May 13, 2025.

View file

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

View file

@ -5,10 +5,8 @@ import math
import os import os
import time import time
import re import re
from quart import request from flask 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()
@ -21,7 +19,6 @@ 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
@ -42,13 +39,7 @@ 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)
twocolon_list = deprecated('import from suou instead')(_twocolon_list) def twocolon_list(s: str | None) -> list[str]:
if not s:
async def get_request_form() -> dict: return []
""" return [x.strip() for x in s.split('::')]
Get the request form as HTTP x-www-form-urlencoded dict
NEW 0.5.0
"""
return dict(await request.form)

View file

@ -1,32 +1,27 @@
import sys import sys
from quart import Blueprint, render_template from flask import Blueprint, render_template, __version__ as flask_version
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/')
async def about(): def about():
return await render_template('about.html', return render_template('about.html',
quart_version=quart_version, flask_version=flask_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/')
async def terms(): def terms():
return await render_template('terms.html') return render_template('terms.html')
@bp.route('/privacy/') @bp.route('/privacy/')
async def privacy(): def privacy():
return await render_template('privacy.html') return render_template('privacy.html')
@bp.route('/rules/') @bp.route('/rules/')
async def rules(): def rules():
return await render_template('rules.html') return render_template('rules.html')

View file

@ -1,90 +1,63 @@
from __future__ import annotations from __future__ import annotations
import enum import os, sys
import logging
import sys
import re import re
import datetime import datetime
from typing import Mapping from typing import Mapping
from quart import Blueprint, render_template, request, redirect, flash from flask import Blueprint, abort, render_template, request, redirect, flash
from quart_auth import AuthUser, login_required, login_user, logout_user, current_user from flask_login import 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, get_request_form from ..utils import age_and_days
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: UserLoader current_user: User
logger = logging.getLogger(__name__)
bp = Blueprint('accounts', __name__) bp = Blueprint('accounts', __name__)
from ..accounts import LoginStatus, check_login @bp.route('/login', methods=['GET', 'POST'])
def login():
if request.method == 'POST' and request.form['username']:
@bp.get('/login') username = request.form['username']
async def login(): password = request.form['password']
return await render_template('login.html')
@bp.post('/login')
async def post_login():
form = await get_request_form()
# TODO schema validator
username: str = form['username']
password: str = form['password']
if '@' in username: if '@' in username:
user_q = select(User).where(User.email == username) user = db.session.execute(select(User).where(User.email == username)).scalar()
else: else:
user_q = select(User).where(User.username == username) user = db.session.execute(select(User).where(User.username == username)).scalar()
async with db as session: if user and '$' not in user.passhash:
user = (await session.execute(user_q)).scalar() flash('You need to reset your password following the procedure.')
return render_template('login.html')
match check_login(user, password): elif not user or not user.check_password(password):
case LoginStatus.SUCCESS: flash('Invalid username or password')
remember_for = int(form.get('remember', 0)) return render_template('login.html')
elif not user.is_active:
flash('Your account is suspended')
else:
remember_for = int(request.form.get('remember', 0))
if remember_for > 0: if remember_for > 0:
login_user(UserLoader(user.get_id()), remember=True) login_user(user, remember=True, duration=datetime.timedelta(days=remember_for))
else: else:
login_user(UserLoader(user.get_id())) login_user(user)
return redirect(request.args.get('next', '/')) return redirect(request.args.get('next', '/'))
case LoginStatus.ERROR: return render_template('login.html')
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')
async def logout(): def logout():
logout_user() logout_user()
await flash('Logged out. Come back soon~') 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 bool(current_user) return current_user and current_user.is_authenticated
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(form['birthday']) f['gdpr_birthday'] = datetime.date.fromisoformat(request.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
@ -95,75 +68,61 @@ async def validate_register_form() -> dict:
except ValueError: except ValueError:
raise ValueError('Invalid date format') raise ValueError('Invalid date format')
f['username'] = form['username'].lower() f['username'] = request.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'] = form.get('full_name') f['display_name'] = request.form.get('full_name')
if form['password'] != form['confirm_password']: if request.form['password'] != request.form['confirm_password']:
raise ValueError('Passwords do not match.') raise ValueError('Passwords do not match.')
f['passhash'] = generate_password_hash(form['password']) f['passhash'] = generate_password_hash(request.form['password'])
f['email'] = form['email'] or None f['email'] = request.form['email'] or None,
is_ip_banned: bool = await _check_ip_bans() if _currently_logged_in() and not request.form.get('confirm_another'):
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 form.get('legal'): if not request.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
class RegisterStatus(enum.Enum): @bp.route('/register', methods=['GET', 'POST'])
SUCCESS = 0 def register():
ERROR = 1 if request.method == 'POST' and request.form['username']:
USERNAME_TAKEN = 2
IP_BANNED = 3
@bp.post('/register')
async def register_post():
try: try:
user_data = await validate_register_form() user_data = validate_register_form()
except ValueError as e: except ValueError as e:
if e.args: if e.args:
await flash(e.args[0]) flash(e.args[0])
return await render_template('register.html') return render_template('register.html')
try: try:
async with db as session: db.session.execute(insert(User).values(**user_data))
await session.execute(insert(User).values(**user_data))
await flash('Account created successfully. You can now log in.') db.session.commit()
flash('Account created successfully. You can now log in.')
return redirect(request.args.get('next', '/')) return redirect(request.args.get('next', '/'))
except Exception as e: except Exception as e:
sys.excepthook(*sys.exc_info()) sys.excepthook(*sys.exc_info())
await flash('Unable to create account (possibly your username is already taken)') flash('Unable to create account (possibly your username is already taken)')
return await render_template('register.html') return render_template('register.html')
@bp.get('/register') return render_template('register.html')
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
async def settings(): def settings():
if request.method == 'POST': if request.method == 'POST':
form = await get_request_form()
async with db as session:
changes = False changes = False
user = current_user.user user = current_user
color_scheme = COLOR_SCHEMES[form.get('color_scheme')] if 'color_scheme' in form else None color_scheme = COLOR_SCHEMES[request.form.get('color_scheme')] if 'color_scheme' in request.form else None
color_theme: int = int(form.get('color_theme')) if 'color_theme' in form else None color_theme = int(request.form.get('color_theme')) if 'color_theme' in request.form else None
biography: str = form.get('biography') biography = request.form.get('biography')
display_name: str = form.get('display_name') display_name = request.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()
@ -174,9 +133,9 @@ async def settings():
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:
session.add(user) db.session.add(user)
session.commit() db.session.commit()
await flash('Changes saved!') flash('Changes saved!')
return await render_template('usersettings.html') return render_template('usersettings.html')

View file

@ -2,37 +2,29 @@
import datetime import datetime
from functools import wraps from functools import wraps
import os
from typing import Callable from typing import Callable
import warnings import warnings
from quart import Blueprint, abort, redirect, render_template, request, send_from_directory, url_for from flask import Blueprint, abort, redirect, render_template, request, url_for
from quart_auth import current_user from flask_login import current_user
from markupsafe import Markup from markupsafe import Markup
from sqlalchemy import insert, select, update from sqlalchemy import insert, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from suou import additem, not_implemented from suou import additem, not_implemented
import logging
logger = logging.getLogger(__name__)
from freak import UserLoader, app_config
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: UserLoader current_user: User
## TODO make admin interface ## TODO make admin interface
def admin_required(func: Callable): def admin_required(func: Callable):
@wraps(func) @wraps(func)
async def wrapper(*a, **ka): def wrapper(**ka):
user: User = current_user.user user: User = current_user
if not user or not user.is_administrator: if not user.is_authenticated or not user.is_administrator:
abort(403) abort(403)
return await func(*a, **ka) return func(**ka)
return wrapper return wrapper
@ -69,8 +61,7 @@ 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)
async def remove_content(target, reason_code: int): def remove_content(target, reason_code: int):
async with db as session:
if isinstance(target, Post): if isinstance(target, Post):
target.removed_at = datetime.datetime.now() target.removed_at = datetime.datetime.now()
target.removed_by_id = current_user.id target.removed_by_id = current_user.id
@ -79,7 +70,7 @@ async def remove_content(target, reason_code: int):
target.removed_at = datetime.datetime.now() target.removed_at = datetime.datetime.now()
target.removed_by_id = current_user.id target.removed_by_id = current_user.id
target.removed_reason = reason_code target.removed_reason = reason_code
session.add(target) db.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)):
@ -98,25 +89,25 @@ def get_content(target) -> str | None:
REPORT_ACTIONS = {} REPORT_ACTIONS = {}
@additem(REPORT_ACTIONS, '1') @additem(REPORT_ACTIONS, '1')
async def accept_report(target, source: PostReport, session: AsyncSession): def accept_report(target, source: PostReport):
if source.is_critical(): if source.is_critical():
warnings.warn('attempted remove on a critical report case, striking instead', UserWarning) warnings.warn('attempted remove on a critical report case, striking instead', UserWarning)
return await strike_report(target, source) return strike_report(target, source)
await remove_content(target, source.reason_code) remove_content(target, source.reason_code)
source.update_status = REPORT_UPDATE_COMPLETE source.update_status = REPORT_UPDATE_COMPLETE
session.add(source) db.session.add(source)
await session.commit() db.session.commit()
@additem(REPORT_ACTIONS, '2') @additem(REPORT_ACTIONS, '2')
async def strike_report(target, source: PostReport, session: AsyncSession): def strike_report(target, source: PostReport):
await remove_content(target, source.reason_code) remove_content(target, source.reason_code)
author = get_author(target) author = get_author(target)
if author: if author:
await session.execute(insert(UserStrike).values( db.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,
@ -130,22 +121,22 @@ async def strike_report(target, source: PostReport, session: AsyncSession):
author.banned_reason = source.reason_code author.banned_reason = source.reason_code
source.update_status = REPORT_UPDATE_COMPLETE source.update_status = REPORT_UPDATE_COMPLETE
session.add(source) db.session.add(source)
await session.commit() db.session.commit()
@additem(REPORT_ACTIONS, '0') @additem(REPORT_ACTIONS, '0')
async def reject_report(target, source: PostReport, session: AsyncSession): def reject_report(target, source: PostReport):
source.update_status = REPORT_UPDATE_REJECTED source.update_status = REPORT_UPDATE_REJECTED
session.add(source) db.session.add(source)
await session.commit() db.session.commit()
@additem(REPORT_ACTIONS, '3') @additem(REPORT_ACTIONS, '3')
async def withhold_report(target, source: PostReport, session: AsyncSession): def withhold_report(target, source: PostReport):
source.update_status = REPORT_UPDATE_ON_HOLD source.update_status = REPORT_UPDATE_ON_HOLD
session.add(source) db.session.add(source)
await session.commit() db.session.commit()
@additem(REPORT_ACTIONS, '4') @additem(REPORT_ACTIONS, '4')
@ -157,81 +148,71 @@ def escalate_report(target, source: PostReport):
@bp.route('/admin/') @bp.route('/admin/')
@admin_required @admin_required
async def homepage(): def homepage():
return await render_template('admin/admin_home.html') return render_template('admin/admin_home.html')
@bp.route('/admin/style.css')
async def style_css():
return redirect(f'//{app_config.server_name}/static/admin/style.css'), 303
@bp.route('/admin/reports/') @bp.route('/admin/reports/')
@admin_required @admin_required
async def reports(): def reports():
report_list = await db.paginate(select(PostReport).order_by(PostReport.id.desc())) report_list = db.paginate(select(PostReport).order_by(PostReport.id.desc()))
return await render_template('admin/admin_reports.html', return 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
async def report_detail(id: int): def report_detail(id: int):
async with db as session: report = db.session.execute(select(PostReport).where(PostReport.id == id)).scalar()
report = (await session.execute(select(PostReport).where(PostReport.id == id))).scalar()
if report is None: if report is None:
abort(404) abort(404)
target = await report.target()
if target is None:
abort(404)
if request.method == 'POST': if request.method == 'POST':
form = await get_request_form() action = REPORT_ACTIONS[request.form['do']]
action = REPORT_ACTIONS[form['do']] action(report.target(), report)
await action(target, report, session)
return redirect(url_for('admin.reports')) return redirect(url_for('admin.reports'))
return await render_template('admin/admin_report_detail.html', report=report, return 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
async def strikes(): def strikes():
strike_list = await db.paginate(select(UserStrike).order_by(UserStrike.id.desc())) strike_list = db.paginate(select(UserStrike).order_by(UserStrike.id.desc()))
return await render_template('admin/admin_strikes.html', return 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
async def users(): def users():
user_list = await db.paginate(select(User).order_by(User.joined_at.desc()), page=int(request.args.get('page', 1))) user_list = db.paginate(select(User).order_by(User.joined_at.desc()))
return await render_template('admin/admin_users.html', return 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
async def user_detail(id: int): def user_detail(id: int):
async with db as session: u = db.session.execute(select(User).where(User.id == id)).scalar()
u = (await session.execute(select(User).where(User.id == id))).scalar()
if u is None: if u is None:
abort(404) abort(404)
if request.method == 'POST': if request.method == 'POST':
form = await get_request_form() action = request.form['do']
action = form['do']
if action == 'suspend': if action == 'suspend':
u.banned_at = datetime.datetime.now() u.banned_at = datetime.datetime.now()
u.banned_by_id = current_user.id u.banned_by_id = current_user.id
u.banned_reason = REPORT_REASONS.get(form.get('reason'), 0) u.banned_reason = REPORT_REASONS.get(request.form.get('reason'), 0)
db.session.commit()
elif action == 'unsuspend': elif action == 'unsuspend':
u.banned_at = None u.banned_at = None
u.banned_by_id = None u.banned_by_id = None
u.banned_until = None u.banned_until = None
u.banned_reason = None u.banned_reason = None
db.session.commit()
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(form.get('reason'), 0) u.banned_reason = REPORT_REASONS.get(request.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 await render_template('admin/admin_user_detail.html', u=u, return render_template('admin/admin_user_detail.html', u=u,
report_reasons=REPORT_REASON_STRINGS, account_status_string=colorized_account_status_string, strikes=strikes) report_reasons=REPORT_REASON_STRINGS, account_status_string=colorized_account_status_string, strikes=strikes)

View file

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

View file

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

View file

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

View file

@ -2,21 +2,19 @@
import datetime import datetime
from quart import Blueprint, abort, flash, redirect, render_template, request from flask import Blueprint, abort, flash, redirect, render_template, request
from quart_auth import current_user, login_required from flask_login import current_user, login_required
from sqlalchemy import select, update from sqlalchemy import select
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
async def edit_post(id): def edit_post(id):
async with db as session: p: Post | None = db.session.execute(select(Post).where(Post.id == id, Post.author == current_user)).scalar()
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)
@ -24,17 +22,16 @@ async def edit_post(id):
abort(403) abort(403)
if request.method == 'POST': if request.method == 'POST':
form = await get_request_form() text = request.form['text']
text = form['text'] privacy = int(request.form.get('privacy', '0'))
privacy = int(form.get('privacy', '0'))
await session.execute(update(Post).where(Post.id == id).values( db.session.execute(db.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()
)) ))
await session.commit() db.session.commit()
await flash('Your changes have been saved') flash('Your changes have been saved')
return redirect(p.url()), 303 return redirect(p.url()), 303
return await render_template('edit.html', p=p) return render_template('edit.html', p=p)

View file

@ -1,77 +1,65 @@
from flask import Blueprint, render_template, redirect, abort, request
from __future__ import annotations from flask_login import current_user
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, Member, Post, User, db from ..models import Guild, Post, db
from ..algorithms import public_timeline, top_guilds_query, topic_timeline from ..algorithms import public_timeline, top_guilds_query, topic_timeline
current_user: UserLoader
bp = Blueprint('frontpage', __name__) bp = Blueprint('frontpage', __name__)
@bp.route('/') @bp.route('/')
async def homepage(): def homepage():
async with db as session:
top_communities = [(x[0], x[1], x[2]) for x in top_communities = [(x[0], x[1], x[2]) for x in
(await session.execute(top_guilds_query().limit(10))).fetchall()] db.session.execute(top_guilds_query().limit(10)).fetchall()]
if current_user: if current_user and current_user.is_authenticated:
# 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 await render_template('landing.html', top_communities=top_communities) return render_template('landing.html', top_communities=top_communities)
@bp.route('/explore/') @bp.route('/explore/')
async def explore(): 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>/')
async def guild_feed(name): def guild_feed(name):
async with db as session: guild: Guild | None = db.session.execute(select(Guild).where(Guild.name == name)).scalar()
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 = await db.paginate(topic_timeline(name)) posts = db.paginate(topic_timeline(name))
return await render_template( return 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>/')
async def guild_feed_r(name): 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"])
async def search(): def search():
if request.method == "POST": if request.method == "POST":
form = await get_request_form() q = request.form["q"]
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 await render_template( return render_template(
"search.html", "search.html",
results=results, results=results,
q = q q = q
) )
return await render_template("search.html") return render_template("search.html")

View file

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

View file

@ -1,65 +1,56 @@
from __future__ import annotations
from quart import Blueprint, render_template, request from flask import Blueprint, render_template, request
from quart_auth import current_user, login_required from flask_login import current_user, login_required
from sqlalchemy import insert, select from ..models import REPORT_TARGET_COMMENT, REPORT_TARGET_POST, ReportReason, post_report_reasons, Comment, Post, PostReport, REPORT_REASONS, db
from suou import Snowflake
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
async def report_post(id: int): def report_post(id: int):
async with db as session: p: Post | None = db.session.execute(db.select(Post).where(Post.id == id)).scalar()
p: Post | None = (await session.execute(select(Post).where(Post.id == id))).scalar()
if p is None: if p is None:
return await render_template('reports/report_404.html', target_type = 1), 404 return render_template('reports/report_404.html', target_type = 1), 404
if p.author_id == current_user.id: if p.author_id == current_user.id:
return await render_template('reports/report_self.html', back_to_url=p.url()), 403 return render_template('reports/report_self.html', back_to_url=p.url()), 403
if request.method == 'POST': if request.method == 'POST':
reason = request.args['reason'] reason = request.args['reason']
await session.execute(insert(PostReport).values( db.session.execute(db.insert(PostReport).values(
author_id = current_user.id, author_id = current_user.id,
target_type = REPORT_TARGET_POST, target_type = REPORT_TARGET_POST,
target_id = id, target_id = id,
reason_code = REPORT_REASONS[reason] reason_code = REPORT_REASONS[reason]
)) ))
session.commit() db.session.commit()
return await render_template('reports/report_done.html', back_to_url='/=' + Snowflake(p.id).to_b32l()) return render_template('reports/report_done.html', back_to_url=p.url())
return await render_template('reports/report_post.html', id = id, return 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
async def report_comment(id: int): def report_comment(id: int):
async with db as session: c: Comment | None = db.session.execute(db.select(Comment).where(Comment.id == id)).scalar()
c: Comment | None = (await session.execute(select(Comment).where(Comment.id == id))).scalar()
if c is None: if c is None:
return await render_template('reports/report_404.html', target_type = 2), 404 return render_template('reports/report_404.html', target_type = 2), 404
if c.author_id == current_user.id: if c.author_id == current_user.id:
return await render_template('reports/report_self.html', back_to_url=c.parent_post.url()), 403 return render_template('reports/report_self.html', back_to_url=c.parent_post.url()), 403
if request.method == 'POST': if request.method == 'POST':
reason = request.args['reason'] reason = request.args['reason']
session.execute(insert(PostReport).values( db.session.execute(db.insert(PostReport).values(
author_id = current_user.id, author_id = current_user.id,
target_type = REPORT_TARGET_COMMENT, target_type = REPORT_TARGET_COMMENT,
target_id = id, target_id = id,
reason_code = REPORT_REASONS[reason] reason_code = REPORT_REASONS[reason]
)) ))
session.commit() db.session.commit()
return await render_template('reports/report_done.html', return render_template('reports/report_done.html',
back_to_url=c.parent_post.url()) back_to_url=c.parent_post.url())
return await render_template('reports/report_comment.html', id = id, return render_template('reports/report_comment.html', id = id,
report_reasons = post_report_reasons, description_text=description_text) report_reasons = post_report_reasons, description_text=description_text)

View file

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

View file

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