diff --git a/CHANGELOG.md b/CHANGELOG.md index 157b5af..25a8067 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,40 @@ # Changelog +## 0.5.0 + +- Switched to Quart framework. This implies everything is `async def` now. +- **BREAKING**: `SERVER_NAME` env variable now contains the domain name. `DOMAIN_NAME` has been removed. +- libsuou bumped to 0.6.0 +- Added several REST routes. Change needed due to pending [frontend separation](https://nekode.yusur.moe/yusur/vigil). +- Deprecated the old web routes except for `/report` and `/admin` + +## 0.4.0 + +- Added dependency to [SUOU](https://github.com/yusurko/suou) library +- Users can now block each other + + Blocking a user prevents them from seeing your comments, posts (standalone or in feed) and profile +- Added user strikes: a strike logs the content of a removed message for future use +- Added ✨**color themes**✨ +- Posts may now be deleted by author. If it has comments, comments are not spared +- If a user for some reason can't post, their post is blocked and they can choose to post it onto another community. Previously it got posted to the user page. +- Moderators (and admins) have now access to mod tools + + Allowed operations: change display name, description, restriction status, and exile (guild-local ban) members + + Site administrators and guild owners can add moderators +- Guilds can have restricted posting/commenting now. Unmoderated guilds always have +- Administrators can claim ownership of abandoned guilds +- Admins can now suspend users from admin panel +- Implemented guild subscriptions (not as in $$$, yes as in the follow button) +- Minimum karma requirement for creating a guild is now configurable via env variable `FREAK_CREATE_GUILD_THRESHOLD` (previously hardcoded at 15) +- Users can now set their display name, biography and color theme in `/settings` +- Impressum can now be set in .env, e.g. `IMPRESSUM='Acme Ltd.::1 Short Island::Old York, Divided States::Responsible: ::Donald Duck'` Lines are separated by two colons. **Versions before 0.4.0 CAN'T BE RUN in German-speaking countries** as of 2025 +- Several aesthetic improvements + +## 0.3.3 + +- Fixed bugs in templates introduced in 0.3.2 +- Improved karma management +- Fixed og: meta tags missing + ## 0.3.2 - Fixed administrator users not being able to create +guilds diff --git a/README.md b/README.md index 347ffd9..8d1a4e9 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,91 @@ # Freak -(´ω\`) \ No newline at end of file +> \~(´ω\`)\~ +> (Josip Broz Tito, possibly) + +**Freak** (as in extremely interested into something, NOT as in predator) is a in-development FOSS and sovereign alternative to Reddit (and an attempt to revive Ruqqus from scratch). The socio-moral reasons are beyond the scope of this README. + +## Installation + +* First make sure you have these requirements: + * Unix-like OS (Docker container, Linux or MacOS are all good). + * **Python** >=3.10. Recommended to use a virtualenv (unless in Docker lol). + * **PostgreSQL** at least 16. + * **Redis**/Valkey (as of 0.5.0 unused in codebase -_-). + * **Docker** and **Docker Compose**. + * A server machine with a public IP address and shell access (mandatory for production, optional for development/staging). + * First time? I recommend a VPS. The cheapest one starts at €5/month, half a Spotify subscription. + * You must have **shell access**. FTP only is not enough. + * A domain (mandatory for production). + * You must have bought it beforehand. Don't have? `.xyz` are like $2 or $3 on Namecheap[^1] + * For development, tweaking `/etc/hosts` or plain running on `localhost:5000` is usually enough. + * A reverse proxy (i.e. Caddy or nginx) listening on ports 80 and 443. Reminder to set `APP_IS_BEHIND_PROXY=1` in `.env` !!! + * Electricity. + * Will to not give up. +* Clone this repository. +* Fill in `.env` with the necessary information. + * `SERVER_NAME` (see above) + * `APP_NAME` + * `DATABASE_URL` (hint: `postgresql://username:password@localhost/dbname`) + * `SECRET_KEY` (you can generate one with the command `cat /dev/random | tr -dc A-Za-z0-9_. | head -c 56`) + * `PRIVATE_ASSETS` (you must provide the icon stylesheets here. Useful for custom CSS / scripts as well) + * `APP_IS_BEHIND_PROXY` (mandatory if behind reverse proxy or NAT) + * `IMPRESSUM` (if you host or serve your site in Germany[^2]. Lines are separated by double colons `::`) +* Adjust `docker-compose.yml` to your liking. +* Run `docker compose build`. +* Create a systemd unit file looking like this: +```systemd +[Unit] +Description=Freak +## using Caddy? replace nginx.service with Caddy.service. Yes, twice +Wants=nginx.service docker.service +After=nginx.service docker.service + +[Service] +Type=simple +## REPLACE it with your path +WorkingDirectory=/path/to/repository/freak +ExecStart=/usr/bin/docker compose up +ExecReload=/usr/bin/docker compose run freak bash ./docker-run.sh r +ExecStop=/usr/bin/docker compose down + +[Install] +WantedBy=multi-user.target +``` +* Copy the file to `/usr/lib/systemd/system` (with root access) +* Run `sudo systemctl enable --now freak.service` +* Expect no red text or weird error gibberish. If there is, you did not follow the tutorial: read it from the start again. +* Congratulations! Your Freak instance is up and running + + +[^1]: Namecheap is an American company. Don't trust American companies. +[^2]: Not legal advice. + +## FAQ + +### Why another Reddit clone? + +I felt like it. + +### Will Freak be federated? + +It's on the roadmap. However, it probably won't be fully functional if not after at least twenty feature releases. Therefore, wait patiently. + +Freak is currently implementing the [SIS](https://yusur.moe/protocols/sis.html). + +### What is your legal contact / Impressum? + +You have to configure it yourself by setting `IMPRESSUM` in `.env`. + +I only write the code. I am not accountable for Your use (see [License](#license)). + +## License + +Licensed under the [Apache License, Version 2.0](LICENSE), a non-copyleft free and open source license. + +This is a hobby project, made available “AS IS”, with __no warranty__ express or implied. + +I (sakuragasaki46) may NOT be held accountable for Your use of my code. + +> It's pointless to file a lawsuit because you feel damaged, and it's only going to turn against you. What a waste of money you could have spent on a vacation or charity, or invested in stocks. + diff --git a/alembic/versions/29a8d663c7ce_.py b/alembic/versions/29a8d663c7ce_.py new file mode 100644 index 0000000..f9e7cae --- /dev/null +++ b/alembic/versions/29a8d663c7ce_.py @@ -0,0 +1,92 @@ +"""upgrade to 0.4.0 + +NOTICE: REVISIONS BEFORE 0.3.1 ARE LOST FOR GOOD + +get over it and move on: the recommended way to upgrade is via +python3 -m freak -U + +Revision ID: 29a8d663c7ce +Revises: +Create Date: 2025-06-17 21:55:16.145111 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '29a8d663c7ce' +down_revision: Union[str, None] = '7122c8715ff9' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('freak_user_block', + sa.Column('actor_id', sa.BigInteger(), nullable=False), + sa.Column('target_id', sa.BigInteger(), nullable=False), + sa.ForeignKeyConstraint(['actor_id'], ['freak_user.id'], ), + sa.ForeignKeyConstraint(['target_id'], ['freak_user.id'], ), + sa.PrimaryKeyConstraint('actor_id', 'target_id') + ) + op.create_table('freak_user_strike', + sa.Column('id', sa.LargeBinary(length=16), nullable=False), + sa.Column('user_id', sa.BigInteger(), nullable=False), + sa.Column('target_type', sa.SmallInteger(), nullable=False), + sa.Column('target_id', sa.BigInteger(), nullable=False), + sa.Column('target_content', sa.String(length=4096), nullable=True), + sa.Column('reason_code', sa.SmallInteger(), nullable=False), + sa.Column('issued_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=True), + sa.Column('issued_by_id', sa.BigInteger(), nullable=True), + sa.ForeignKeyConstraint(['issued_by_id'], ['freak_user.id'], ), + sa.ForeignKeyConstraint(['user_id'], ['freak_user.id'], ondelete='cascade'), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('freak_member', + sa.Column('id', sa.LargeBinary(length=16), nullable=False), + sa.Column('user_id', sa.BigInteger(), nullable=True), + sa.Column('guild_id', sa.BigInteger(), nullable=True), + sa.Column('is_approved', sa.Boolean(), server_default=sa.text('false'), nullable=False), + sa.Column('is_subscribed', sa.Boolean(), server_default=sa.text('false'), nullable=False), + sa.Column('is_moderator', sa.Boolean(), server_default=sa.text('false'), nullable=False), + sa.Column('banned_at', sa.DateTime(), nullable=True), + sa.Column('banned_by_id', sa.BigInteger(), nullable=True), + sa.Column('banned_reason', sa.SmallInteger(), server_default=sa.text('0'), nullable=True), + sa.Column('banned_until', sa.DateTime(), nullable=True), + sa.Column('banned_message', sa.String(length=256), nullable=True), + sa.ForeignKeyConstraint(['banned_by_id'], ['freak_user.id'], name='user_banner_id'), + sa.ForeignKeyConstraint(['guild_id'], ['freak_topic.id'], ), + sa.ForeignKeyConstraint(['user_id'], ['freak_user.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('user_id', 'guild_id', name='member_user_topic') + ) + op.add_column('freak_topic', sa.Column('is_restricted', sa.Boolean(), server_default=sa.text('false'), nullable=False)) + op.add_column('freak_topic', sa.Column('is_public', sa.Boolean(), server_default=sa.text('true'), nullable=False)) + op.drop_column('freak_topic', 'privacy') + op.add_column('freak_user', sa.Column('pronouns', sa.Integer(), server_default=sa.text('0'), nullable=False)) + op.add_column('freak_user', sa.Column('biography', sa.String(length=1024), nullable=True)) + op.add_column('freak_user', sa.Column('is_approved', sa.Boolean(), server_default=sa.text('false'), nullable=False)) + op.add_column('freak_user', sa.Column('invited_by_id', sa.BigInteger(), nullable=True)) + op.create_foreign_key('user_inviter_id', 'freak_user', 'freak_user', ['invited_by_id'], ['id']) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint('user_inviter_id', 'freak_user', type_='foreignkey') + op.drop_column('freak_user', 'invited_by_id') + op.drop_column('freak_user', 'is_approved') + op.drop_column('freak_user', 'biography') + op.drop_column('freak_user', 'pronouns') + op.add_column('freak_topic', sa.Column('privacy', sa.SMALLINT(), server_default=sa.text('0'), autoincrement=False, nullable=True)) + op.drop_column('freak_topic', 'is_public') + op.drop_column('freak_topic', 'is_restricted') + op.drop_table('freak_member') + op.drop_table('freak_user_strike') + op.drop_table('freak_user_block') + # ### end Alembic commands ### diff --git a/alembic/versions/6d418df3c72f_.py b/alembic/versions/6d418df3c72f_.py new file mode 100644 index 0000000..ccb6063 --- /dev/null +++ b/alembic/versions/6d418df3c72f_.py @@ -0,0 +1,34 @@ +"""empty message + +Revision ID: 6d418df3c72f +Revises: 90c7d0098efe +Create Date: 2025-07-07 13:37:51.667620 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '6d418df3c72f' +down_revision: Union[str, None] = '90c7d0098efe' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint('comment_parent_post_id', 'freak_comment', type_='foreignkey') + op.create_foreign_key('comment_parent_post_id', 'freak_comment', 'freak_post', ['parent_post_id'], ['id'], ondelete='cascade') + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint('comment_parent_post_id', 'freak_comment', type_='foreignkey') + op.create_foreign_key('comment_parent_post_id', 'freak_comment', 'freak_post', ['parent_post_id'], ['id']) + # ### end Alembic commands ### diff --git a/alembic/versions/7122c8715ff9_.py b/alembic/versions/7122c8715ff9_.py new file mode 100644 index 0000000..67eb85a --- /dev/null +++ b/alembic/versions/7122c8715ff9_.py @@ -0,0 +1,28 @@ +"""autogenerated to allow downgrade to nothing as a bugfix + +Revision ID: 7122c8715ff9 +Revises: 29a8d663c7ce +Create Date: 2025-06-17 22:05:14.803669 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '7122c8715ff9' +down_revision: Union[str, None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + pass + + +def downgrade() -> None: + """Downgrade schema.""" + pass diff --git a/alembic/versions/90c7d0098efe_.py b/alembic/versions/90c7d0098efe_.py new file mode 100644 index 0000000..07390d9 --- /dev/null +++ b/alembic/versions/90c7d0098efe_.py @@ -0,0 +1,32 @@ +"""empty message + +Revision ID: 90c7d0098efe +Revises: 29a8d663c7ce +Create Date: 2025-06-19 01:16:41.120290 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '90c7d0098efe' +down_revision: Union[str, None] = '29a8d663c7ce' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('freak_user', sa.Column('color_theme', sa.SmallInteger(), server_default=sa.text('0'), nullable=False)) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('freak_user', 'color_theme') + # ### end Alembic commands ### diff --git a/docker-compose.yml.example b/docker-compose.yml.example new file mode 100644 index 0000000..cc8f2fe --- /dev/null +++ b/docker-compose.yml.example @@ -0,0 +1,14 @@ + +services: + freak: + build: + context: . + image: freak + ports: + - 5000:5000 + volumes: + - .:/opt/live-app:ro + extra_hosts: + - 'postgres.docker.internal:172.17.0.1' + restart: on-failure:3 + diff --git a/docker-run.sh b/docker-run.sh index 331cbe1..9dbe0e7 100644 --- a/docker-run.sh +++ b/docker-run.sh @@ -6,7 +6,7 @@ start-app() { cp -rv /opt/live-app/{freak,pyproject.toml,docker-run.sh} ./ cp -v /opt/live-app/.env.prod .env pip install -e . - flask --app freak run --host=0.0.0.0 + hypercorn freak:app -b 0.0.0.0:5000 } [[ "$1" = "" ]] && start-app diff --git a/freak/__init__.py b/freak/__init__.py index 9c32442..fcac5d5 100644 --- a/freak/__init__.py +++ b/freak/__init__.py @@ -1,126 +1,268 @@ +import logging +import re from sqlite3 import ProgrammingError +import sys +from typing import Any import warnings -from flask import ( - Flask, g, redirect, render_template, +from quart import ( + Quart, flash, g, jsonify, redirect, render_template, request, send_from_directory, url_for ) import os import dotenv -from flask_login import LoginManager -from flask_wtf.csrf import CSRFProtect -from sqlalchemy import select +from quart_auth import AuthUser, QuartAuth, Action as QA_Action, current_user +from quart_wtf import CSRFProtect +from sqlalchemy import inspect, select +from suou import Snowflake, ssv_list, yesno from werkzeug.routing import BaseConverter -from sassutils.wsgi import SassMiddleware +from suou.sass import SassAsyncMiddleware +from suou.quart import negotiate +from hypercorn.middleware import ProxyFixMiddleware -__version__ = '0.3.2' +from suou.configparse import ConfigOptions, ConfigValue +from suou import twocolon_list, WantsContentType + +from .colors import color_themes, theme_classes + +__version__ = '0.5.0-dev50' APP_BASE_DIR = os.path.dirname(os.path.dirname(__file__)) -dotenv.load_dotenv() +if not dotenv.load_dotenv(): + warnings.warn('.env not loaded; application may break!', RuntimeWarning) -app = Flask(__name__) -app.secret_key = os.getenv('SECRET_KEY') -app.config['SQLALCHEMY_DATABASE_URI'] = os.getenv('DATABASE_URL') +class AppConfig(ConfigOptions): + secret_key = ConfigValue(required=True) + database_url = ConfigValue(required=True) + app_name = ConfigValue() + server_name = ConfigValue() + force_server_name = ConfigValue(cast=yesno, default=True) + private_assets = ConfigValue(cast=ssv_list) + app_is_behind_proxy = ConfigValue(cast=int, default=0) + impressum = ConfigValue(cast=twocolon_list, default='') + create_guild_threshold = ConfigValue(cast=int, default=15, prefix='freak_') + # v-- deprecated --v + jquery_url = ConfigValue(default='https://ajax.googleapis.com/ajax/libs/jquery/3.7.1/jquery.min.js') + # ^----------------^ + +app_config = AppConfig() + +logging.basicConfig(level=logging.WARNING) + +logger = logging.getLogger(__name__) + +app = Quart(__name__) +app.secret_key = app_config.secret_key +app.config['SQLALCHEMY_DATABASE_URI'] = app_config.database_url app.config['JSONIFY_PRETTYPRINT_REGULAR'] = False +app.config['QUART_AUTH_DURATION'] = 365 * 24 * 60 * 60 -from .models import db, User, Post -from .iding import id_from_b32l, id_to_b32l +if app_config.server_name and app_config.force_server_name: + app.config['SERVER_NAME'] = app_config.server_name + + +## DO NOT ADD LOCAL IMPORTS BEFORE THIS LINE + +from .accounts import UserLoader +from .models import Guild, db, User, Post # SASS -app.wsgi_app = SassMiddleware(app.wsgi_app, dict( +app.asgi_app = SassAsyncMiddleware(app.asgi_app, dict( freak=('static/sass', 'static/css', '/static/css', True) )) +# proxy fix +if app_config.app_is_behind_proxy: + app.asgi_app = ProxyFixMiddleware( + app.asgi_app, trusted_hops=app_config.app_is_behind_proxy, mode='legacy' + ) + class SlugConverter(BaseConverter): regex = r'[a-z0-9]+(?:-[a-z0-9]+)*' class B32lConverter(BaseConverter): regex = r'_?[a-z2-7]+' def to_url(self, value): - return id_to_b32l(value) + return Snowflake(value).to_b32l() def to_python(self, value): - return id_from_b32l(value) + return Snowflake.from_b32l(value) app.url_map.converters['slug'] = SlugConverter app.url_map.converters['b32l'] = B32lConverter -db.init_app(app) +db.bind(app_config.database_url) csrf = CSRFProtect(app) -login_manager = LoginManager(app) -login_manager.login_view = 'accounts.login' + + + +# TODO configure quart_auth +login_manager = QuartAuth(app, user_class= UserLoader) from . import filters PRIVATE_ASSETS = os.getenv('PRIVATE_ASSETS', '').split() +post_count_cache = 0 +user_count_cache = 0 + @app.context_processor -def _inject_variables(): +async def _inject_variables(): + global post_count_cache, user_count_cache + try: + post_count = await Post.count() + user_count = await User.active_count() + except Exception as e: + logger.error(f'cannot compute post_count: {e}') + post_count = post_count_cache + user_count = user_count_cache + else: + post_count_cache = post_count + user_count_cache = user_count + return { - 'app_name': os.getenv('APP_NAME'), - 'domain_name': os.getenv('DOMAIN_NAME'), + 'app_name': app_config.app_name, + 'app_version': __version__, + 'server_name': app_config.server_name, 'url_for_css': (lambda name, **kwargs: url_for('static', filename=f'css/{name}.css', **kwargs)), - 'private_scripts': [x for x in 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')], - 'jquery_url': os.getenv('JQUERY_URL') or 'https://ajax.googleapis.com/ajax/libs/jquery/3.7.1/jquery.min.js', - 'post_count': Post.count(), - 'user_count': User.active_count() + 'jquery_url': app_config.jquery_url, + 'post_count': post_count, + 'user_count': user_count, + 'colors': color_themes, + 'theme_classes': theme_classes, + 'impressum': '\n'.join(app_config.impressum).replace('_', ' ') } -@login_manager.user_loader -def _inject_user(userid): +@app.before_request +async def _load_user(): try: - return db.session.execute(select(User).where(User.id == userid)).scalar() - except Exception: - warnings.warn(f'cannot retrieve user {userid} from db', RuntimeWarning) + await current_user._load() + except RuntimeError as e: + logger.error(f'{e}') g.no_user = True - return None + + +@app.after_request +async def _unload_user(resp): + try: + await current_user._unload() + except RuntimeError as e: + logger.error(f'{e}') + return resp + + +def redact_url_password(u: str | Any) -> str | Any: + if not isinstance(u, str): + return u + return re.sub(r':[^@:/ ]+@', ':***@', u) + +async def error_handler_for(status: int, message: str, template: str): + match negotiate(): + case WantsContentType.JSON: + return jsonify({'error': f'{message}', 'status': status}), status + case WantsContentType.HTML: + if request.path.startswith('/admin'): + return await render_template('admin/' + template, message=f'{message}'), status + return await render_template(template, message=f'{message}'), status + case WantsContentType.PLAIN: + return f'{message} (HTTP {status})', status, {'content-type': 'text/plain; charset=UTF-8'} @app.errorhandler(ProgrammingError) -def error_db(body): +async def error_db(body): g.no_user = True - warnings.warn(f'No database access! (url is {app.config['SQLALCHEMY_DATABASE_URI']})', RuntimeWarning) - fix_database_url() - if request.method in ('HEAD', 'GET') and not 'retry' in request.args: - return redirect(request.url + ('&' if '?' in request.url else '?') + 'retry=1'), 307, {'cache-control': 'private,no-cache,must-revalidate,max-age=0'} - return render_template('500.html'), 500 + logger.error(f'No database access! (url is {redact_url_password(app.config['SQLALCHEMY_DATABASE_URI'])!r})', RuntimeWarning) + return await error_handler_for(500, body, '500.html') @app.errorhandler(400) -def error_400(body): - return render_template('400.html'), 400 +async def error_400(body): + return await error_handler_for(400, body, '400.html') + +@app.errorhandler(401) +async def error_401(body): + match negotiate(): + case WantsContentType.HTML: + return redirect(url_for('accounts.login', next=request.path)) + case _: + return await error_handler_for(401, 'Please log in.', 'login.html') + @app.errorhandler(403) -def error_403(body): - return render_template('403.html'), 403 +async def error_403(body): + return await error_handler_for(403, body, '403.html') + +async def find_guild_or_user(name: str) -> str | None: + """ + Used in 404 error handler. + + Returns an URL to redirect or None for no redirect. + """ + + if hasattr(g, 'no_user'): + return None + + # do not execute for non-browsers_ + if 'Mozilla/' not in request.user_agent.string: + return None + + async with db as session: + gu = (await session.execute(select(Guild).where(Guild.name == name))).scalar() + user = (await session.execute(select(User).where(User.username == name))).scalar() + + if gu is not None: + await flash(f'There is nothing at /{name}. Luckily, a guild with name {gu.handle()} happens to exist. Next time, remember to add + before!') + return gu.url() + + if user is not None: + await flash(f'There is nothing at /{name}. Luckily, a user named {user.handle()} happens to exist. Next time, remember to add @ before!') + return user.url() + + return None @app.errorhandler(404) -def error_404(body): - return render_template('404.html'), 404 +async def error_404(body): + try: + if mo := re.match(r'/([a-z0-9_-]+)/?', request.path): + alternative = await find_guild_or_user(mo.group(1)) + if alternative is not None: + return redirect(alternative), 302 + except Exception as e: + logger.error(f'Exception in find_guild_or_user: {e}') + pass + if app_config.server_name not in (None, request.host): + logger.warning(f'request host {request.host!r} is different from configured server name {app_config.server_name!r}') + if request.referrer: + logger.warning(f'(referrer is {request.referrer!r}') + if request.host == request.referrer: + return {"error": "Loop detected"}, 508 + return redirect('//' + app_config.server_name + request.full_path), 307 + return await error_handler_for(404, 'Not found', '404.html') @app.errorhandler(405) -def error_405(body): - return render_template('405.html'), 405 +async def error_405(body): + return await error_handler_for(405, body, '405.html') @app.errorhandler(451) -def error_451(body): - return render_template('451.html'), 451 +async def error_451(body): + return await error_handler_for(451, body, '451.html') @app.errorhandler(500) -def error_500(body): +async def error_500(body): g.no_user = True - return render_template('500.html'), 500 + return await error_handler_for(500, body, '500.html') @app.route('/favicon.ico') -def favicon_ico(): - return send_from_directory(APP_BASE_DIR, 'favicon.ico') +async def favicon_ico(): + return await send_from_directory(APP_BASE_DIR, 'favicon.ico') @app.route('/robots.txt') -def robots_txt(): - return send_from_directory(APP_BASE_DIR, 'robots.txt') +async def robots_txt(): + return await send_from_directory(APP_BASE_DIR, 'robots.txt') from .website import blueprints @@ -130,8 +272,8 @@ for bp in blueprints: from .ajax import bp app.register_blueprint(bp) -from .rest import rest_bp -app.register_blueprint(rest_bp) +from .rest import bp +app.register_blueprint(bp) diff --git a/freak/__main__.py b/freak/__main__.py index df77c43..0f15538 100644 --- a/freak/__main__.py +++ b/freak/__main__.py @@ -1,4 +1,6 @@ +import asyncio from .cli import main -main() \ No newline at end of file +asyncio.run(main()) + diff --git a/freak/accounts.py b/freak/accounts.py new file mode 100644 index 0000000..db34516 --- /dev/null +++ b/freak/accounts.py @@ -0,0 +1,89 @@ + + +import logging +import enum + +from sqlalchemy import select +from sqlalchemy.orm import selectinload +from suou.sqlalchemy.asyncio import AsyncSession +from .models import User, db +from quart_auth import AuthUser, Action as _Action + +logger = logging.getLogger(__name__) + +class LoginStatus(enum.Enum): + SUCCESS = 0 + ERROR = 1 + SUSPENDED = 2 + PASS_EXPIRED = 3 + +def check_login(user: User | None, password: str) -> LoginStatus: + try: + if user is None: + return LoginStatus.ERROR + if ('$' not in user.passhash) and user.email: + return LoginStatus.PASS_EXPIRED + if not user.is_active: + return LoginStatus.SUSPENDED + if user.check_password(password): + return LoginStatus.SUCCESS + except Exception as e: + logger.error(f'{e}') + return LoginStatus.ERROR + + +class UserLoader(AuthUser): + """ + Loads user from the session. + + *WARNING* requires to be awaited before request before usage! + + Actual User object is at .user; other attributes are proxied. + """ + def __init__(self, auth_id: str | None, action: _Action= _Action.PASS): + self._auth_id = auth_id + self._auth_obj = None + self._auth_sess: AsyncSession | None = None + self.action = action + + @property + def auth_id(self) -> str | None: + return self._auth_id + + @property + async def is_authenticated(self) -> bool: + await self._load() + return self._auth_id is not None + + async def _load(self): + if self._auth_obj is None and self._auth_id is not None: + async with db as session: + self._auth_obj = (await session.execute(select(User).where(User.id == int(self._auth_id)))).scalar() + if self._auth_obj is None: + raise RuntimeError('failed to fetch user') + + def __getattr__(self, key): + if self._auth_obj is None: + raise RuntimeError('user is not loaded') + return getattr(self._auth_obj, key) + + def __bool__(self): + return self._auth_obj is not None + + @property + def session(self): + return self._auth_sess + + async def _unload(self): + # user is not expected to mutate + if self._auth_sess: + await self._auth_sess.rollback() + + @property + def user(self): + return self._auth_obj + + id: int + username: str + display_name: str + color_theme: int diff --git a/freak/ajax.py b/freak/ajax.py index 83a4185..19e964c 100644 --- a/freak/ajax.py +++ b/freak/ajax.py @@ -1,72 +1,171 @@ ''' -AJAX hooks for the website. +AJAX hooks for the OLD frontend. -2025 DEPRECATED in favor of /v1/ (REST) +DEPRECATED in 0.5 in favor of /v1/ (REST) ''' +from __future__ import annotations + import re -from flask import Blueprint, request -from .models import Topic, db, User, Post, PostUpvote -from flask_login import current_user, login_required +from quart import Blueprint, abort, flash, redirect, request +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 quart_auth import current_user, login_required + +current_user: UserLoader bp = Blueprint('ajax', __name__) @bp.route('/username_availability/') @bp.route('/ajax/username_availability/') -def username_availability(username: str): - is_valid = re.fullmatch('[a-z0-9_-]+', username) is not None +async def username_availability(username: str): + is_valid = username_is_legal(username) if is_valid: - user = db.session.execute(db.select(User).where(User.username == username)).scalar() + async with db as session: + user = (await session.execute(select(User).where(User.username == username))).scalar() - is_available = user is None or user == current_user + is_available = user is None or user == current_user.user else: is_available = False return { 'status': 'ok', 'is_valid': is_valid, - 'is_available': is_available, + 'is_available': is_available } -@bp.route('/guild_name_availability/') -def guild_name_availability(name: str): - is_valid = re.fullmatch('[a-z0-9_-]+', username) is not None +@bp.route('/guild_name_availability/') +async def guild_name_availability(name: str): + is_valid = username_is_legal(name) if is_valid: - gd = db.session.execute(db.select(Topic).where(Topic.name == name)).scalar() + async with db as session: + gd = (await session.execute(select(Guild).where(Guild.name == name))).scalar() - is_available = gd is None + is_available = gd is None else: is_available = False return { 'status': 'ok', 'is_valid': is_valid, - 'is_available': is_available, + 'is_available': is_available } @bp.route('/comments//upvote', methods=['POST']) @login_required -def post_upvote(id): - o = request.form['o'] - p: Post | None = db.session.execute(db.select(Post).where(Post.id == id)).scalar() +async def post_upvote(id): + form = await get_request_form() + o = form['o'] + async with db as session: + p: Post | None = (await session.execute(select(Post).where(Post.id == id))).scalar() - if p is None: - return { 'status': 'fail', 'message': 'Post not found' }, 404 + if p is None: + return { 'status': 'fail', 'message': 'Post not found' }, 404 + + cur_score = await p.upvoted_by(current_user.user) + + match (o, cur_score): + case ('1', 0) | ('1', -1): + await session.execute(delete(PostUpvote).where(PostUpvote.c.post_id == p.id, PostUpvote.c.voter_id == current_user.id, PostUpvote.c.is_downvote == True)) + await session.execute(insert(PostUpvote).values(post_id = p.id, voter_id = current_user.id, is_downvote = False)) + case ('0', _): + await session.execute(delete(PostUpvote).where(PostUpvote.c.post_id == p.id, PostUpvote.c.voter_id == current_user.id)) + case ('-1', 1) | ('-1', 0): + await session.execute(delete(PostUpvote).where(PostUpvote.c.post_id == p.id, PostUpvote.c.voter_id == current_user.id, PostUpvote.c.is_downvote == False)) + await session.execute(insert(PostUpvote).values(post_id = p.id, voter_id = current_user.id, is_downvote = True)) + case ('1', 1) | ('-1', -1): + pass + case _: + await session.rollback() + return { 'status': 'fail', 'message': 'Invalid score' }, 400 + + await session.commit() + return { 'status': 'ok', 'count': await p.upvotes() } + +@bp.route('/@/block', methods=['POST']) +@login_required +async def block_user(username): + form = await get_request_form() + + async with db as session: + u = (await session.execute(select(User).where(User.username == username))).scalar() + + if u is None: + abort(404) + + is_block = 'reverse' not in form + is_unblock = form.get('reverse') == '1' + + if is_block: + if current_user.has_blocked(u): + await flash(f'{u.handle()} is already blocked') + else: + await session.execute(insert(UserBlock).values( + actor_id = current_user.id, + target_id = u.id + )) + await flash(f'{u.handle()} is now blocked') + + if is_unblock: + if not current_user.has_blocked(u): + await flash('You didn\'t block this user') + else: + await session.execute(delete(UserBlock).where( + UserBlock.c.actor_id == current_user.id, + UserBlock.c.target_id == u.id + )) + await flash(f'Removed block on {u.handle()}') + + return redirect(request.args.get('next', u.url())), 303 + +@bp.route('/+/subscribe', methods=['POST']) +@login_required +async def subscribe_guild(name): + form = await get_request_form() + + async with db as session: + gu = (await session.execute(select(Guild).where(Guild.name == name))).scalar() + + if gu is None: + abort(404) + + is_join = 'reverse' not in form + is_leave = form.get('reverse') == '1' + + membership = (await session.execute(select(Member).where(Member.guild == gu, Member.user_id == current_user.id))).scalar() + + if is_join: + if membership is None: + membership = (await session.execute(insert(Member).values( + guild_id = gu.id, + user_id = current_user.id, + is_subscribed = True + ).returning(Member))).scalar() + elif membership.is_subscribed == False: + membership.is_subscribed = True + await session.add(membership) + else: + return redirect(gu.url()), 303 + await flash(f"You are now subscribed to {gu.handle()}") + + if is_leave: + if membership is None: + return redirect(gu.url()), 303 + elif membership.is_subscribed == True: + membership.is_subscribed = False + await session.add(membership) + else: + return redirect(gu.url()), 303 + + await session.commit() + await flash(f"Unsubscribed from {gu.handle()}.") - if o == '1': - db.session.execute(db.delete(PostUpvote).where(PostUpvote.c.post_id == p.id, PostUpvote.c.voter_id == current_user.id, PostUpvote.c.is_downvote == True)) - db.session.execute(db.insert(PostUpvote).values(post_id = p.id, voter_id = current_user.id, is_downvote = False)) - elif o == '0': - db.session.execute(db.delete(PostUpvote).where(PostUpvote.c.post_id == p.id, PostUpvote.c.voter_id == current_user.id)) - elif o == '-1': - db.session.execute(db.delete(PostUpvote).where(PostUpvote.c.post_id == p.id, PostUpvote.c.voter_id == current_user.id, PostUpvote.c.is_downvote == False)) - db.session.execute(db.insert(PostUpvote).values(post_id = p.id, voter_id = current_user.id, is_downvote = True)) - else: - return { 'status': 'fail', 'message': 'Invalid score' }, 400 + return redirect(gu.url()), 303 - db.session.commit() - return { 'status': 'ok', 'count': p.upvotes() } diff --git a/freak/algorithms.py b/freak/algorithms.py index efc7bf6..cba3c0e 100644 --- a/freak/algorithms.py +++ b/freak/algorithms.py @@ -1,31 +1,55 @@ -from flask_login import current_user -from sqlalchemy import func, select -from .models import db, Post, Topic, User +from quart_auth import current_user +from sqlalchemy import and_, distinct, func, select +from suou import not_implemented + +from .models import Comment, Member, Post, Guild, User + + def cuser() -> User: - return current_user if current_user.is_authenticated else None + return current_user.user if current_user else None + +def cuser_id() -> int: + return current_user.id if current_user else None def public_timeline(): return select(Post).join(User, User.id == Post.author_id).where( - Post.privacy == 0, User.not_suspended(), Post.not_removed() + Post.privacy == 0, User.not_suspended(), Post.not_removed(), User.has_not_blocked(Post.author_id, cuser_id()) ).order_by(Post.created_at.desc()) -def topic_timeline(topic_name): - return select(Post).join(Topic).join(User, User.id == Post.author_id).where( - Post.privacy == 0, Topic.name == topic_name, User.not_suspended(), Post.not_removed() +def topic_timeline(gname): + return select(Post).join(Guild, Guild.id == Post.topic_id).join(User, User.id == Post.author_id).where( + Post.privacy == 0, Guild.name == gname, User.not_suspended(), Post.not_removed(), User.has_not_blocked(Post.author_id, cuser_id()) ).order_by(Post.created_at.desc()) -def user_timeline(user_id): +def user_timeline(user: User): return select(Post).join(User, User.id == Post.author_id).where( - Post.visible_by(cuser()), User.id == user_id, User.not_suspended(), Post.not_removed() + Post.visible_by(cuser_id()), Post.author_id == user.id, User.not_suspended(), Post.not_removed(), User.has_not_blocked(Post.author_id, cuser_id()) ).order_by(Post.created_at.desc()) +def new_comments(p: Post): + return select(Comment).join(Post, Post.id == Comment.parent_post_id).join(User, User.id == Comment.author_id + ).where(Comment.parent_post_id == p.id, Comment.parent_comment_id == None, Comment.not_removed(), User.has_not_blocked(Comment.author_id, cuser_id()) + ).order_by(Comment.created_at.desc()) + def top_guilds_query(): - q_post_count = func.count().label('post_count') - qr = select(Topic, q_post_count)\ - .join(Post, Post.topic_id == Topic.id).group_by(Topic)\ - .having(q_post_count > 5).order_by(q_post_count.desc()) + q_post_count = func.count(distinct(Post.id)).label('post_count') + q_sub_count = func.count(distinct(Member.id)).label('sub_count') + qr = select(Guild, q_post_count, q_sub_count)\ + .join(Post, Post.topic_id == Guild.id, isouter=True)\ + .join(Member, and_(Member.guild_id == Guild.id, Member.is_subscribed == True), isouter=True)\ + .group_by(Guild).having(q_post_count > 5).order_by(q_post_count.desc(), q_sub_count.desc()) return qr + +@not_implemented() +class Algorithms: + """ + Return SQL queries for algorithms. + """ + def __init__(self, me: User | None): + self.me = me + + \ No newline at end of file diff --git a/freak/cli.py b/freak/cli.py index 9fd7930..63b508a 100644 --- a/freak/cli.py +++ b/freak/cli.py @@ -4,22 +4,41 @@ import argparse import os import subprocess -from sqlalchemy import create_engine -from . import __version__ as version -from .models import db +from sqlalchemy import create_engine, select +from sqlalchemy.orm import Session +from . import __version__ as version, app_config +from .models import User, db def make_parser(): parser = argparse.ArgumentParser() parser.add_argument('--version', '-v', action='version', version=version) parser.add_argument('--upgrade', '-U', action='store_true', help='create or upgrade schema') + parser.add_argument('--flush', '-H', action='store_true', help='recompute karma for all users') return parser -def main(): +async def main(): args = make_parser().parse_args() + + engine = create_engine(os.getenv('DATABASE_URL')) if args.upgrade: - db.metadata.create_all(create_engine(os.getenv('DATABASE_URL'))) - subprocess.Popen(['alembic', 'upgrade', 'head']).wait() + ret_code = subprocess.Popen(['alembic', 'upgrade', 'head']).wait() + if ret_code != 0: + print(f'Schema upgrade failed (code: {ret_code})') + exit(ret_code) + # if the alembic/versions folder is empty + await db.create_all(engine) print('Schema upgraded!') - print(f'Visit ') + if args.flush: + cnt = 0 + async with db as session: + + for u in (await session.execute(select(User))).scalars(): + u.recompute_karma() + cnt += 1 + session.add(u) + session.commit() + print(f'Recomputed karma of {cnt} users') + + print(f'Visit ') diff --git a/freak/colors.py b/freak/colors.py new file mode 100644 index 0000000..39171eb --- /dev/null +++ b/freak/colors.py @@ -0,0 +1,39 @@ + + +from collections import namedtuple + + +ColorTheme = namedtuple('ColorTheme', 'code name') + +## actual color codes are set in CSS + +color_themes = [ + ColorTheme(0, 'Default'), + ColorTheme(1, 'Rei'), + ColorTheme(2, 'Ai'), + ColorTheme(3, 'Aqua'), + ColorTheme(4, 'Neru'), + ColorTheme(5, 'Gumi'), + ColorTheme(6, 'Emu'), + ColorTheme(7, 'Spacegray'), + ColorTheme(8, 'Haku'), + ColorTheme(9, 'Miku'), + ColorTheme(10, 'Defoko'), + ColorTheme(11, 'Kaito'), + ColorTheme(12, 'Meiko'), + ColorTheme(13, 'WhatsApp'), + ColorTheme(14, 'Teto'), + ColorTheme(15, 'Ruby') +] + +def theme_classes(color_code: int): + cl = [] + sch, th = divmod(color_code, 256) + if sch == 1: + cl.append('color-scheme-light') + if sch == 2: + cl.append('color-scheme-dark') + if 1 <= th <= 15: + cl.append(f'color-theme-{th}') + + return ' '.join(cl) diff --git a/freak/filters.py b/freak/filters.py index 4c1d0be..f085ef9 100644 --- a/freak/filters.py +++ b/freak/filters.py @@ -1,78 +1,48 @@ -import re, markdown -from markdown.inlinepatterns import InlineProcessor, SimpleTagInlineProcessor -import xml.etree.ElementTree as etree +import markdown from markupsafe import Markup +from suou import Siq, Snowflake +from suou.markdown import StrikethroughExtension, SpoilerExtension, PingExtension from . import app -from .iding import id_to_b32l - -#### MARKDOWN EXTENSIONS #### - -class StrikethroughExtension(markdown.extensions.Extension): - def extendMarkdown(self, md: markdown.Markdown, md_globals=None): - postprocessor = StrikethroughPostprocessor(md) - md.postprocessors.register(postprocessor, 'strikethrough', 0) - -class StrikethroughPostprocessor(markdown.postprocessors.Postprocessor): - pattern = re.compile(r"~~(((?!~~).)+)~~", re.DOTALL) - - def run(self, html): - return re.sub(self.pattern, self.convert, html) - - def convert(self, match): - return '' + match.group(1) + '' - - -### XXX it currently only detects spoilers that are not at the beginning of the line. To be fixed. -class SpoilerExtension(markdown.extensions.Extension): - def extendMarkdown(self, md: markdown.Markdown, md_globals=None): - md.inlinePatterns.register(SimpleTagInlineProcessor(r'()>!(.*?)!<', 'span class="spoiler"'), 'spoiler', 14) - - @classmethod - def patch_blockquote_processor(cls): - """Patch BlockquoteProcessor to make Spoiler prevail over blockquotes.""" - from markdown.blockprocessors import BlockQuoteProcessor - BlockQuoteProcessor.RE = re.compile(r'(^|\n)[ ]{0,3}>(?!!)[ ]?(.*)') - -# make spoilers prevail over blockquotes +# make spoilers prevail over blockquotes SpoilerExtension.patch_blockquote_processor() -class MentionPattern(InlineProcessor): - def __init__(self, regex, url_prefix: str): - super().__init__(regex) - self.url_prefix = url_prefix - def handleMatch(self, m, data): - el = etree.Element('a') - el.attrib['href'] = self.url_prefix + m.group(1) - el.text = m.group(0) - return el, m.start(0), m.end(0) - -class PingExtension(markdown.extensions.Extension): - def extendMarkdown(self, md: markdown.Markdown, md_globals=None): - md.inlinePatterns.register(MentionPattern(r'@([a-zA-Z0-9_-]{2,32})', '/@'), 'ping_mention', 14) - md.inlinePatterns.register(MentionPattern(r'\+([a-zA-Z0-9_-]{2,32})', '/+'), 'ping_mention', 14) - @app.template_filter() def to_markdown(text, toc = False): extensions = [ 'tables', 'footnotes', 'fenced_code', 'sane_lists', StrikethroughExtension(), SpoilerExtension(), - ## XXX untested - PingExtension() + PingExtension({'@': '/@', '+': '/+'}) ] if toc: extensions.append('toc') return Markup(markdown.Markdown(extensions=extensions).convert(text)) +app.template_filter('markdown')(to_markdown) + @app.template_filter() def to_b32l(n): - return id_to_b32l(n) + return Snowflake(n).to_b32l() +app.template_filter('b32l')(to_b32l) @app.template_filter() -def append(text, l): +def to_cb32(n): + return '0' + Siq.from_bytes(n).to_cb32() + +app.template_filter('cb32')(to_cb32) + +@app.template_filter() +def append(text, l: list): l.append(text) return None + +@app.template_filter() +def faint_paren(text: str): + if not '(' in text: + return text + t1, t2, t3 = text.partition('(') + return Markup('{0} {1}').format(t1, t2 + t3) \ No newline at end of file diff --git a/freak/iding.py b/freak/iding.py index b028f53..3295393 100644 --- a/freak/iding.py +++ b/freak/iding.py @@ -1,17 +1,24 @@ """ +DEPRECATED use suou.snowflake instead. + PSA: this module is for the LEGACY (v2) iding. -For the SIQ-based ID's (upcoming 0.4), see suou.iding +For the SIQ-based ID's, see suou.iding . + +The suou library also provides snowflake support. """ import base64 import os import time +from suou.functools import deprecated + epoch = 1577833200000 machine_id = int(os.getenv("MACHINE_ID", "0")) machine_counter = 0 +@deprecated('use SnowflakeGen(). Planned for removal in 0.5') def new_id(*, from_date = None): global machine_counter @@ -28,14 +35,16 @@ def new_id(*, from_date = None): ((machine_counter := machine_counter + 1) % 1024) ) -def id_to_b32l(n): +@deprecated('use suou.Snowflake.to_b32l() instead') +def id_to_b32l(n: int) -> str: return ( '_' if n < 0 else '' ) + base64.b32encode( (-n if n < 0 else n).to_bytes(10, 'big') ).decode().lstrip('A').lower() -def id_from_b32l(s, *, n_bytes=10): +@deprecated('use suou.Snowflake.from_b32l() instead') +def id_from_b32l(s: str) -> int: return (-1 if s.startswith('_') else 1) * int.from_bytes( base64.b32decode(s.lstrip('_').upper().rjust(16, 'A').encode()), 'big' ) diff --git a/freak/models.py b/freak/models.py index 99872f4..d34d567 100644 --- a/freak/models.py +++ b/freak/models.py @@ -2,51 +2,67 @@ from __future__ import annotations +import asyncio from collections import namedtuple import datetime -from functools import lru_cache +from functools import partial from operator import or_ +import re from threading import Lock -from sqlalchemy import Column, String, ForeignKey, and_, text, \ +from typing import Any, Callable +from quart_auth import current_user +from sqlalchemy import Column, Integer, String, ForeignKey, UniqueConstraint, and_, insert, text, \ CheckConstraint, Date, DateTime, Boolean, func, BigInteger, \ - SmallInteger, select, insert, update, create_engine, Table -from sqlalchemy.orm import Relationship, declarative_base, relationship -from flask_sqlalchemy import SQLAlchemy -from flask_login import AnonymousUserMixin + SmallInteger, select, update, Table +from sqlalchemy.orm import Relationship, relationship +from suou.sqlalchemy_async import SQLAlchemy +from suou import SiqType, Snowflake, Wanted, deprecated, makelist, not_implemented, want_isodate +from suou.sqlalchemy import create_session, declarative_base, id_column, parent_children, snowflake_column from werkzeug.security import check_password_hash -import os -from .iding import new_id, id_to_b32l -from .utils import age_and_days, get_remote_addr, timed_cache +from . import app_config +from .utils import get_remote_addr + +from suou import timed_cache, age_and_days + +import logging + +logger = logging.getLogger(__name__) ## Constants and enums +## NOT IN USE: User has .banned_at and .is_disabled_by_user USER_ACTIVE = 0 USER_INACTIVE = 1 USER_BANNED = 2 -ReportReason = namedtuple('ReportReason', 'num_code code description') +ReportReason = namedtuple('ReportReason', 'num_code code description extra', defaults=dict(extra=None)) post_report_reasons = [ + ## emergency ReportReason(110, 'hate_speech', 'Extreme hate speech / terrorism'), - ReportReason(121, 'csam', 'Child abuse or endangerment'), + ReportReason(121, 'csam', 'Child abuse or endangerment', extra=dict(suspend=True)), ReportReason(142, 'revenge_sxm', 'Revenge porn'), ReportReason(122, 'black_market', 'Sale or promotion of regulated goods (e.g. firearms)'), + ## urgent ReportReason(171, 'xxx', 'Pornography'), ReportReason(111, 'tasteless', 'Extreme violence / gore'), ReportReason(180, 'impersonation', 'Impersonation'), ReportReason(141, 'doxing', 'Diffusion of PII (personally identifiable information)'), - ReportReason(123, 'copyviol', 'This is my creation and someone else is using it without my permission/license'), - ReportReason(140, 'bullying', 'Harassment, bullying, or suicide incitement'), + ## less urgent ReportReason(112, 'lgbt_hate', 'Hate speech against LGBTQ+ or women'), + ReportReason(140, 'bullying', 'Harassment, bullying, or suicide incitement'), ReportReason(150, 'security_exploit', 'Dangerous security exploit or violation'), + ReportReason(160, 'spam', 'Unsolicited advertising'), ReportReason(190, 'false_information', 'False or deceiving information'), - ReportReason(210, 'underage', 'Presence in violation of age limits (i.e. under 13, or minor in adult spaces)') + ReportReason(123, 'copyviol', 'This is my creation and someone else is using it without my permission/license'), + ## minor (unironically) + ReportReason(210, 'underage', 'Presence in violation of age limits (i.e. under 13, or minor in adult spaces)', extra=dict(suspend=True)) ] REPORT_REASON_STRINGS = { **{x.num_code: x.description for x in post_report_reasons}, **{x.code: x.description for x in post_report_reasons} } -REPORT_REASONS = {x.code: x.num_code for x in post_report_reasons} +REPORT_REASONS: dict[str, int] = {x.code: x.num_code for x in post_report_reasons} REPORT_TARGET_POST = 1 REPORT_TARGET_COMMENT = 2 @@ -56,23 +72,56 @@ REPORT_UPDATE_COMPLETE = 1 REPORT_UPDATE_REJECTED = 2 REPORT_UPDATE_ON_HOLD = 3 +USERNAME_RE = r'[a-z2-9_-][a-z0-9_-]+' + +ILLEGAL_USERNAMES = tuple(( + ## masspings and administrative claims + 'me everyone here room all any server app dev devel develop nil none ' + 'founder owner admin administrator mod modteam moderator sysop some ' + ## fictitious users and automations + 'nobody somebody deleted suspended default bot developer undefined null ' + 'ai automod clanker automoderator assistant privacy anonymous removed assistance ' + ## law enforcement corps and slurs because yes + 'pedo rape rapist nigger retard ncmec police cops 911 childsafety ' + 'report dmca login logout security order66 gestapo ss hitler heilhitler kgb ' + 'pedophile lolicon giphy tenor csam cp pedobear lolita lolice thanos ' + 'loli lolicon kkk pnf adl cop tranny google trustandsafety safety ice fbi nsa it ' + ## VVVVIP + 'potus realdonaldtrump elonmusk teddysphotos mrbeast jkrowling pewdiepie ' + 'elizabethii elizabeth2 king queen pontifex hogwarts lumos alohomora isis daesh retards ' +).split()) + +def username_is_legal(username: str) -> bool: + if len(username) < 2 or len(username) > 100: + return False + + if re.fullmatch(USERNAME_RE, username) is None: + return False + + if username in ILLEGAL_USERNAMES: + return False + return True + +def want_User(o: User | Any | None, *, prefix: str = '', var_name: str = '') -> User | None: + if isinstance(o, User): + return o + if o is None: + return None + logger.warning(f'{prefix}: {repr(var_name) + " has " if var_name else ""}invalid type {o.__class__.__name__}, expected User') + return None + ## END constants and enums -Base = declarative_base() +Base = declarative_base(app_config.server_name, app_config.secret_key, + snowflake_epoch=1577833200) db = SQLAlchemy(model_class=Base) -def create_session_interactively(): - '''Create a session for querying the database in Python REPL.''' - engine = create_engine(os.getenv('DATABASE_URL')) - return db.Session(bind = engine) +CSI = create_session_interactively = partial(create_session, app_config.database_url) -CSI = create_session_interactively -## TODO replace with suou.declarative_base() - upcoming 0.4 -class BaseModel(Base): - __abstract__ = True +## .accounts requires db +#current_user: UserLoader - id = Column(BigInteger, primary_key=True, default=new_id) ## Many-to-many relationship keys for some reasons have to go ## BEFORE other table definitions. @@ -86,10 +135,22 @@ PostUpvote = Table( Column('is_downvote', Boolean, server_default=text('false')) ) -class User(BaseModel): - __tablename__ = 'freak_user' +UserBlock = Table( + 'freak_user_block', + Base.metadata, + Column('actor_id', BigInteger, ForeignKey('freak_user.id'), primary_key=True), + Column('target_id', BigInteger, ForeignKey('freak_user.id'), primary_key=True) +) - id = Column(BigInteger, primary_key=True, default=new_id, unique=True) + +class User(Base): + __tablename__ = 'freak_user' + __table_args__ = ( + ## XXX this constraint (and the other three at Post, Guild and Comment) cannot be removed!! + UniqueConstraint('id', name='user_id_uniq'), + ) + + id = snowflake_column() username = Column(String(32), CheckConstraint(text("username = lower(username) and username ~ '^[a-z0-9_-]+$'"), name="user_username_valid"), unique=True, nullable=False) display_name = Column(String(64), nullable=False) @@ -102,7 +163,11 @@ class User(BaseModel): is_disabled_by_user = Column(Boolean, server_default=text('false'), nullable=False) karma = Column(BigInteger, server_default=text('0'), nullable=False) legacy_id = Column(BigInteger, nullable=True) - # TODO add pronouns and biography (upcoming 0.4) + + # pronouns must be set via suou.dei.Pronoun.from_short() + pronouns = Column(Integer, server_default=text('0'), nullable=False) + biography = Column(String(1024), nullable=True) + color_theme = Column(SmallInteger, nullable=False, server_default=text('0')) # moderation banned_at = Column(DateTime, nullable=True) @@ -110,31 +175,44 @@ class User(BaseModel): banned_reason = Column(SmallInteger, server_default=text('0'), nullable=True) banned_until = Column(DateTime, nullable=True) banned_message = Column(String(256), nullable=True) + + # invites + is_approved = Column(Boolean, server_default=text('false'), nullable=False) + invited_by_id = Column(BigInteger, ForeignKey('freak_user.id', name='user_inviter_id'), nullable=True) # utilities - #posts = relationship("Post", back_populates='author', ) - upvoted_posts = relationship("Post", secondary=PostUpvote, back_populates='upvoters') - #comments = relationship("Comment", back_populates='author') ## XXX posts and comments relationships are temporarily disabled because they make ## SQLAlchemy fail initialization of models — bricking the app. ## Posts are queried manually anyway - + #posts = relationship("Post", primaryjoin=lambda: #back_populates='author', pr) + upvoted_posts = relationship("Post", secondary=PostUpvote, back_populates='upvoters', lazy='selectin') + #comments = relationship("Comment", back_populates='author', lazy='selectin') + @property def is_disabled(self): - return self.banned_at is not None or self.is_disabled_by_user + now = datetime.datetime.now() + return ( + # suspended + (self.banned_at is not None and (self.banned_until is None or self.banned_until >= now)) or + # self-disabled + self.is_disabled_by_user + ) @property def is_active(self): return not self.is_disabled @property + @deprecated('shadowed by UserLoader.is_authenticated(), and always true') def is_authenticated(self): return True @property + @deprecated('no more in use since switch to Quart') def is_anonymous(self): return False + @deprecated('this representation uses decimal, URLs use b32l') def get_id(self): return str(self.id) @@ -145,29 +223,38 @@ class User(BaseModel): def age(self): return age_and_days(self.gdpr_birthday)[0] - def simple_info(self): + def simple_info(self, *, typed = False): """ Return essential informations for representing a user in the REST """ ## XXX change func name? - return dict( - id = id_to_b32l(self.id), + gg = dict( + id = Snowflake(self.id).to_b32l(), username = self.username, display_name = self.display_name, - age = self.age() - ## TODO add badges? - ) + age = self.age(), + badges = self.badges(), - def reward(self, points=1): + ) + if typed: + gg['type'] = 'user' + return gg + + @deprecated('updates may be not atomic. DO NOT USE until further notice') + async def reward(self, points=1): + """ + Manipulate a user's karma on the fly + """ with Lock(): - db.session.execute(update(User).where(User.id == self.id).values(karma = self.karma + points)) - db.session.commit() + async with db as session: + await session.execute(update(User).where(User.id == self.id).values(karma = self.karma + points)) + await session.commit() def can_create_guild(self): - return self.karma > 15 or self.is_administrator + ## TODO make guild creation requirements fully configurable + return self.karma > app_config.create_guild_threshold or self.is_administrator - ## deprecated alias! - can_create_community = can_create_guild + can_create_community = deprecated('use .can_create_guild()')(can_create_guild) def handle(self): return f'@{self.username}' @@ -176,10 +263,12 @@ class User(BaseModel): return check_password_hash(self.passhash, password) @classmethod - @timed_cache(1800) - def active_count(cls) -> int: + @timed_cache(1800, async_=True) + async def active_count(cls) -> int: active_th = datetime.datetime.now() - datetime.timedelta(days=30) - return db.session.execute(select(func.count(User.id)).select_from(cls).join(Post, Post.author_id == User.id).where(Post.created_at >= active_th).group_by(User.id)).scalar() + async with db as session: + count = (await session.execute(select(func.count(User.id)).select_from(cls).join(Post, Post.author_id == User.id).where(Post.created_at >= active_th).group_by(User.id))).scalar() + return count def __repr__(self): return f'<{self.__class__.__name__} id:{self.id!r} username:{self.username!r}>' @@ -188,10 +277,125 @@ class User(BaseModel): def not_suspended(cls): return or_(User.banned_at == None, User.banned_until <= datetime.datetime.now()) -class Topic(BaseModel): - __tablename__ = 'freak_topic' + async def has_blocked(self, other: User | None) -> bool: + if not want_User(other, var_name='other', prefix='User.has_blocked()'): + return False + async with db as session: + block_exists = (await session.execute(select(UserBlock).where(UserBlock.c.actor_id == self.id, UserBlock.c.target_id == other.id))).scalar() + return bool(block_exists) - id = Column(BigInteger, primary_key=True, default=new_id, unique=True) + async def is_blocked_by(self, other: User | None) -> bool: + if not want_User(other, var_name='other', prefix='User.is_blocked_by()'): + return False + async with db as session: + block_exists = (await session.execute(select(UserBlock).where(UserBlock.c.actor_id == other.id, UserBlock.c.target_id == self.id))).scalar() + return bool(block_exists) + + def has_blocked_q(self, other_id: int): + return select(UserBlock).where(UserBlock.c.actor_id == self.id, UserBlock.c.target_id == other_id).exists() + + def blocked_by_q(self, other_id: int): + return select(UserBlock).where(UserBlock.c.actor_id == other_id, UserBlock.c.target_id == self.id).exists() + + @not_implemented() + def end_friendship(self, other: User): + """ + Remove any relationship between two users. + Executed before block. + """ + # TODO implement in 0.5 + ... + + def has_subscriber(self, other: User) -> bool: + # TODO implement in 0.5 + return False #bool(session.execute(select(Friendship).where(...)).scalar()) + + @classmethod + def has_not_blocked(cls, actor: int, target: int): + """ + Filter out a content if the author has blocked current user. Returns a query. + + XXX untested. + """ + + # TODO add recognition + actor_id = actor + target_id = target + + qq= ~select(UserBlock).where(UserBlock.c.actor_id == actor_id, UserBlock.c.target_id == target_id).exists() + return qq + + async def recompute_karma(self): + """ + Recompute karma as of 0.4.0 karma handling + """ + async with db as session: + c = 0 + c += session.execute(select(func.count('*')).select_from(Post).where(Post.author == self)).scalar() + c += session.execute(select(func.count('*')).select_from(PostUpvote).join(Post).where(Post.author == self, PostUpvote.c.is_downvote == False)).scalar() + c -= session.execute(select(func.count('*')).select_from(PostUpvote).join(Post).where(Post.author == self, PostUpvote.c.is_downvote == True)).scalar() + self.karma = c + + return c + + ## TODO are coroutines cacheable? + @timed_cache(60, async_=True) + async def strike_count(self) -> int: + async with db as session: + return (await session.execute(select(func.count('*')).select_from(UserStrike).where(UserStrike.user_id == self.id))).scalar() + + async def moderates(self, gu: Guild) -> bool: + async with db as session: + ## owner + if gu.owner_id == self.id: + return True + ## admin or global mod + if self.is_administrator: + return True + memb = (await session.execute(select(Member).where(Member.user_id == self.id, Member.guild_id == gu.id))).scalar() + + if memb is None: + return False + return memb.is_moderator + + ## TODO check banship? + + @makelist + def badges(self, /): + if self.is_administrator: + yield 'administrator' + + badges: Callable[[], list[str]] + + @classmethod + async def get_by_username(cls, name: str): + """ + Get a user by its username, + """ + user_q = select(User).where(User.username == name) + try: + if current_user: + user_q = user_q.where(~select(UserBlock).where(UserBlock.c.target_id == current_user.id).exists()) + except Exception as e: + logger.error(f'{e}') + + async with db as session: + user = (await session.execute(user_q)).scalar() + return user + +# UserBlock table is at the top !! + +## END User + +ModeratorInfo = namedtuple('ModeratorInfo', 'user is_owner') + +class Guild(Base): + __tablename__ = 'freak_topic' + __table_args__ = ( + UniqueConstraint('id', name='topic_id_uniq'), + ) + + id = snowflake_column() name = Column(String(32), CheckConstraint(text("name = lower(name) AND name ~ '^[a-z0-9_-]+$'"), name='topic_name_valid'), unique=True, nullable=False) display_name = Column(String(64), nullable=False) @@ -199,8 +403,12 @@ class Topic(BaseModel): created_at = Column(DateTime, server_default=func.current_timestamp(), index=True, nullable=False) owner_id = Column(BigInteger, ForeignKey('freak_user.id', name='topic_owner_id'), nullable=True) language = Column(String(16), server_default=text("'en-US'")) - privacy = Column(SmallInteger, server_default=text('0')) + # true: prevent non-members from participating + is_restricted = Column(Boolean, server_default=text('false'), nullable=False) + # false: make the guild invite-only + is_public = Column(Boolean, server_default=text('true'), nullable=False) + # MUST NOT be filled in on post-0.2 instances legacy_id = Column(BigInteger, nullable=True) def url(self): @@ -209,23 +417,156 @@ class Topic(BaseModel): def handle(self): return f'+{self.name}' + async def subscriber_count(self): + async with db as session: + count = (await session.execute(select(func.count('*')).select_from(Member).where(Member.guild == self, Member.is_subscribed == True))).scalar() + return count + # utilities - posts = relationship('Post', back_populates='topic') + owner = relationship(User, foreign_keys=owner_id, lazy='selectin') + posts = relationship('Post', back_populates='guild', lazy='selectin') + + async def post_count(self): + async with db as session: + return (await session.execute(select(func.count('*')).select_from(Post).where(Post.guild == self))).scalar() + + async def has_subscriber(self, other: User) -> bool: + if not want_User(other, var_name='other', prefix='Guild.has_subscriber()'): + return False + async with db as session: + sub_ex = (await session.execute(select(Member).where(Member.user_id == other.id, Member.guild_id == self.id, Member.is_subscribed == True))).scalar() + return bool(sub_ex) + + async def has_exiled(self, other: User) -> bool: + if not want_User(other, var_name='other', prefix='Guild.has_exiled()'): + return False + async with db as session: + u = (await session.execute(select(Member).where(Member.user_id == other.id, Member.guild_id == self.id))).scalar() + return u.is_banned if u else False + + async def allows_posting(self, other: User) -> bool: + async with db as session: + # control owner_id instead of owner: the latter causes MissingGreenletError + if self.owner_id is None: + return False + if other.is_disabled: + return False + mem: Member | None = (await session.execute(select(Member).where(Member.user_id == other.id, Member.guild_id == self.id))).scalar() + if mem and mem.is_banned: + return False + if await other.moderates(self): + return True + if self.is_restricted: + return (mem and mem.is_approved) + return True + + async def moderators(self): + async with db as session: + if self.owner_id: + owner = (await session.execute(select(User).where(User.id == self.owner_id))).scalar() + yield ModeratorInfo(owner, True) + for mem in (await session.execute(select(Member).where(Member.guild_id == self.id, Member.is_moderator == True))).scalars(): + if mem.user != self.owner and not mem.is_banned: + yield ModeratorInfo(mem.user, False) + + async def update_member(self, u: User | Member, /, **values): + if isinstance(u, User): + async with db as session: + m = (await session.execute(select(Member).where(Member.user_id == u.id, Member.guild_id == self.id))).scalar() + if m is None: + m = (await session.execute(insert(Member).values( + guild_id = self.id, + user_id = u.id, + **values + ).returning(Member))).scalar() + if m is None: + raise RuntimeError + return m + else: + m = u + if len(values): + async with db as session: + session.execute(update(Member).where(Member.user_id == u.id, Member.guild_id == self.id).values(**values)) + return m + + def simple_info(self, *, typed=False): + """ + Return essential informations for representing a guild in the REST + """ + ## XXX change func name? + gg = dict( + id = Snowflake(self.id).to_b32l(), + name = self.name, + display_name = self.display_name, + badges = [] + ) + if typed: + gg['type'] = 'guild' + return gg + + async def sub_info(self): + """ + Guild info including subscriber count. + """ + gg = self.simple_info() + gg['subscriber_count'] = await self.subscriber_count() + gg['post_count'] = await self.post_count() + return gg + + +Topic = deprecated('renamed to Guild')(Guild) + +## END Guild + +class Member(Base): + """ + User-Guild relationship. NEW in 0.4.0. + """ + __tablename__ = 'freak_member' + __table_args__ = ( + UniqueConstraint('user_id', 'guild_id', name='member_user_topic'), + ) + + ## Newer tables use SIQ. Older tables will gradually transition to SIQ as well. + id = id_column(SiqType.MANYTOMANY) + user_id = Column(BigInteger, ForeignKey('freak_user.id')) + guild_id = Column(BigInteger, ForeignKey('freak_topic.id')) + is_approved = Column(Boolean, server_default=text('false'), nullable=False) + is_subscribed = Column(Boolean, server_default=text('false'), nullable=False) + is_moderator = Column(Boolean, server_default=text('false'), nullable=False) + + # moderation + banned_at = Column(DateTime, nullable=True) + banned_by_id = Column(BigInteger, ForeignKey('freak_user.id', name='user_banner_id'), nullable=True) + banned_reason = Column(SmallInteger, server_default=text('0'), nullable=True) + banned_until = Column(DateTime, nullable=True) + banned_message = Column(String(256), nullable=True) + + user = relationship(User, primaryjoin = lambda: User.id == Member.user_id, lazy='selectin') + guild = relationship(Guild, lazy='selectin') + banned_by = relationship(User, primaryjoin = lambda: User.id == Member.banned_by_id, lazy='selectin') + + @property + def is_banned(self): + return self.banned_at is not None and (self.banned_until is None or self.banned_until <= datetime.datetime.now()) POST_TYPE_DEFAULT = 0 POST_TYPE_LINK = 1 -class Post(BaseModel): +class Post(Base): __tablename__ = 'freak_post' + __table_args__ = ( + UniqueConstraint('id', name='post_id_uniq'), + ) - id = Column(BigInteger, primary_key=True, default=new_id, unique=True) + id = snowflake_column() slug = Column(String(64), CheckConstraint("slug IS NULL OR (slug = lower(slug) AND slug ~ '^[a-z0-9_-]+$')", name='post_slug_valid'), nullable=True) title = Column(String(256), nullable=False) post_type = Column(SmallInteger, server_default=text('0')) author_id = Column(BigInteger, ForeignKey('freak_user.id', name='post_author_id'), nullable=True) - topic_id = Column(BigInteger, ForeignKey('freak_topic.id', name='post_topic_id'), nullable=True) + topic_id = Column('topic_id', BigInteger, ForeignKey('freak_topic.id', name='post_topic_id'), nullable=True) created_at = Column(DateTime, server_default=func.current_timestamp()) created_ip = Column(String(64), default=get_remote_addr, nullable=False) updated_at = Column(DateTime, nullable=True) @@ -242,47 +583,60 @@ class Post(BaseModel): removed_reason = Column(SmallInteger, nullable=True) # utilities - author: Relationship[User] = relationship("User", lazy='selectin', foreign_keys=[author_id])#, back_populates="posts") - topic = relationship("Topic", back_populates="posts", lazy='selectin') - comments = relationship("Comment", back_populates="parent_post") - upvoters = relationship("User", secondary=PostUpvote, back_populates='upvoted_posts') + author: Relationship[User] = relationship("User", foreign_keys=[author_id], lazy='selectin')#, back_populates="posts") + guild: Relationship[Guild] = relationship("Guild", back_populates="posts", lazy='selectin') + comments = relationship("Comment", back_populates="parent_post", lazy='selectin') + upvoters = relationship("User", secondary=PostUpvote, back_populates='upvoted_posts', lazy='selectin') - def topic_or_user(self) -> Topic | User: - return self.topic or self.author + async def comment_count(self): + async with db as session: + return (await session.execute(select(func.count('*')).select_from(Comment).where(Comment.parent_post == self))).scalar() + + def topic_or_user(self) -> Guild | User: + return self.guild or self.author def url(self): - return self.topic_or_user().url() + '/comments/' + id_to_b32l(self.id) + '/' + (self.slug or '') + return self.topic_or_user().url() + '/comments/' + Snowflake(self.id).to_b32l() + '/' + (self.slug or '') - def generate_slug(self): - return slugify.slugify(self.title, max_length=64) + @not_implemented('slugify is not a dependency as of now') + def generate_slug(self) -> str: + return "slugify.slugify(self.title, max_length=64)" - def upvotes(self) -> int: - return (db.session.execute(select(func.count('*')).select_from(PostUpvote).where(PostUpvote.c.post_id == self.id, PostUpvote.c.is_downvote == False)).scalar() - - db.session.execute(select(func.count('*')).select_from(PostUpvote).where(PostUpvote.c.post_id == self.id, PostUpvote.c.is_downvote == True)).scalar()) + async def upvotes(self) -> int: + async with db as session: + upv = (await session.execute(select(func.count('*')).select_from(PostUpvote).where(PostUpvote.c.post_id == self.id, PostUpvote.c.is_downvote == False))).scalar() + dwv = (await session.execute(select(func.count('*')).select_from(PostUpvote).where(PostUpvote.c.post_id == self.id, PostUpvote.c.is_downvote == True))).scalar() + return upv - dwv - def upvoted_by(self, user: User | AnonymousUserMixin | None): - if not user or not user.is_authenticated: + async def upvoted_by(self, user: User | None): + if not want_User(user, var_name='user', prefix='Post.upvoted_by()'): return 0 - v = db.session.execute(db.select(PostUpvote.c).where(PostUpvote.c.voter_id == user.id, PostUpvote.c.post_id == self.id)).fetchone() - if v: - if v.is_downvote: + async with db as session: + v = (await session.execute(select(PostUpvote.c.is_downvote).where(PostUpvote.c.voter_id == user.id, PostUpvote.c.post_id == self.id))).fetchone() + if v is None: + return 0 + if v == (True,): return -1 - return 1 - return 0 + if v == (False,): + return 1 + logger.warning(f'unexpected value: {v}') + return 0 - def top_level_comments(self, limit=None): - return db.session.execute(select(Comment).where(Comment.parent_comment == None, Comment.parent_post == self).order_by(Comment.created_at.desc()).limit(limit)).scalars() + async def top_level_comments(self, limit=None): + async with db as session: + return (await session.execute(select(Comment).where(Comment.parent_comment == None, Comment.parent_post == self).order_by(Comment.created_at.desc()).limit(limit))).scalars() def report_url(self) -> str: - return '/report/post/' + id_to_b32l(self.id) + return f'/report/post/{Snowflake(self.id):l}' - def report_count(self) -> int: - return db.session.execute(select(func.count('*')).select_from(PostReport).where(PostReport.target_id == self.id, ~PostReport.update_status.in_((1, 2)))).scalar() + async def report_count(self) -> int: + async with db as session: return (await session.execute(select(func.count('*')).select_from(PostReport).where(PostReport.target_id == self.id, ~PostReport.update_status.in_((1, 2))))).scalar() @classmethod - @timed_cache(1800) - def count(cls): - return db.session.execute(select(func.count('*')).select_from(cls)).scalar() + @timed_cache(1800, async_=True) + async def count(cls): + async with db as session: + return (await session.execute(select(func.count('*')).select_from(cls))).scalar() @property def is_removed(self) -> bool: @@ -293,19 +647,44 @@ class Post(BaseModel): return Post.removed_at == None @classmethod - def visible_by(cls, user: User): - return or_(Post.author_id == user.id, Post.privacy.in_((0, 1))) + 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, 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 -class Comment(BaseModel): + def feed_info(self): + return dict( + id=Snowflake(self.id).to_b32l(), + slug = self.slug, + title = self.title, + author = self.author.simple_info(), + to = self.topic_or_user().simple_info(), + created_at = self.created_at + ) + + async def feed_info_counts(self): + pj = self.feed_info() + if self.is_text_post(): + pj['content'] = self.text_content[:181] + (pj['comment_count'], pj['votes'], pj['my_vote']) = await asyncio.gather( + self.comment_count(), + self.upvotes(), + self.upvoted_by(current_user.user) + ) + return pj + +class Comment(Base): __tablename__ = 'freak_comment' + __table_args__ = ( + UniqueConstraint('id', name='comment_id_uniq'), + ) - # tweak to allow remote_side to work - ## XXX will be changed in 0.4 to suou.id_column() - id = Column(BigInteger, primary_key=True, default=new_id, unique=True) + id = snowflake_column() author_id = Column(BigInteger, ForeignKey('freak_user.id', name='comment_author_id'), nullable=True) - parent_post_id = Column(BigInteger, ForeignKey('freak_post.id', name='comment_parent_post_id'), nullable=False) + parent_post_id = Column(BigInteger, ForeignKey('freak_post.id', name='comment_parent_post_id', ondelete='cascade'), nullable=False) parent_comment_id = Column(BigInteger, ForeignKey('freak_comment.id', name='comment_parent_comment_id'), nullable=True) text_content = Column(String(16384), nullable=False) created_at = Column(DateTime, server_default=func.current_timestamp(), index=True) @@ -313,25 +692,38 @@ class Comment(BaseModel): updated_at = Column(DateTime, nullable=True) is_locked = Column(Boolean, server_default=text('false')) + ## DO NOT FILL IN! intended for 0.2 or earlier legacy_id = Column(BigInteger, nullable=True) removed_at = Column(DateTime, nullable=True) removed_by_id = Column(BigInteger, ForeignKey('freak_user.id', name='user_banner_id'), nullable=True) removed_reason = Column(SmallInteger, nullable=True) - author = relationship('User', foreign_keys=[author_id])#, back_populates='comments') - parent_post = relationship("Post", back_populates="comments", foreign_keys=[parent_post_id]) - parent_comment = relationship("Comment", back_populates="child_comments", remote_side=[id]) - child_comments = relationship("Comment", back_populates="parent_comment") + author = relationship('User', foreign_keys=[author_id], lazy='selectin')#, back_populates='comments') + parent_post: Relationship[Post] = relationship("Post", back_populates="comments", foreign_keys=[parent_post_id], lazy='selectin') + parent_comment, child_comments = parent_children('comment', parent_remote_side=Wanted('id')) def url(self): - return self.parent_post.url() + '/comment/' + id_to_b32l(self.id) + return self.parent_post.url() + f'/comment/{Snowflake(self.id):l}' + + async def is_parent_locked(self): + if self.is_locked: + return True + if self.parent_comment_id == None: + return False + async with db as session: + parent = (await session.execute(select(Comment).where(Comment.id == self.parent_comment_id))).scalar() + try: + return parent.is_parent_locked() + except RecursionError: + return True def report_url(self) -> str: - return '/report/comment/' + id_to_b32l(self.id) + return f'/report/comment/{Snowflake(self.id):l}' - def report_count(self) -> int: - return db.session.execute(select(func.count('*')).select_from(PostReport).where(PostReport.target_id == self.id, ~PostReport.update_status.in_((1, 2)))).scalar() + async def report_count(self) -> int: + async with db as session: + return (await session.execute(select(func.count('*')).select_from(PostReport).where(PostReport.target_id == self.id, ~PostReport.update_status.in_((1, 2))))).scalar() @property def is_removed(self) -> bool: @@ -341,8 +733,25 @@ class Comment(BaseModel): def not_removed(cls): return Post.removed_at == None -class PostReport(BaseModel): + async def section_info(self): + obj = dict( + id = Snowflake(self.id).to_b32l(), + parent = dict(id=Snowflake(self.parent_comment_id)) if self.parent_comment_id else None, + locked = await self.is_parent_locked(), + created_at = want_isodate(self.created_at) + ) + if self.is_removed: + obj['removed'] = self.removed_reason + else: + obj['content'] = self.text_content + + return obj + + +class PostReport(Base): __tablename__ = 'freak_postreport' + + id = snowflake_column() author_id = Column(BigInteger, ForeignKey('freak_user.id', name='report_author_id'), nullable=True) target_type = Column(SmallInteger, nullable=False) @@ -352,16 +761,39 @@ class PostReport(BaseModel): created_at = Column(DateTime, server_default=func.current_timestamp()) created_ip = Column(String(64), default=get_remote_addr, nullable=False) - author = relationship('User') + author = relationship('User', lazy='selectin') + + async def target(self): + async with db as session: + if self.target_type == REPORT_TARGET_POST: + return (await session.execute(select(Post).where(Post.id == self.target_id))).scalar() + elif self.target_type == REPORT_TARGET_COMMENT: + return (await session.execute(select(Comment).where(Comment.id == self.target_id))).scalar() + else: + return self.target_id - def target(self): - if self.target_type == REPORT_TARGET_POST: - return db.session.execute(select(Post).where(Post.id == self.target_id)).scalar() - elif self.target_type == REPORT_TARGET_COMMENT: - return db.session.execute(select(Comment).where(Comment.id == self.target_id)).scalar() - else: - return self.target_id + def is_critical(self): + return self.reason_code in ( + 121, 142, 210 + ) + +class UserStrike(Base): + __tablename__ = 'freak_user_strike' + + id = id_column(SiqType.MULTI) + + user_id = Column(BigInteger, ForeignKey('freak_user.id', ondelete='cascade'), nullable=False) + target_type = Column(SmallInteger, nullable=False) + target_id = Column(BigInteger, nullable=False) + target_content = Column(String(4096), nullable=True) + reason_code = Column(SmallInteger, nullable=False) + issued_at = Column(DateTime, server_default=func.current_timestamp()) + issued_by_id = Column(BigInteger, ForeignKey('freak_user.id'), nullable=True) + + user = relationship(User, primaryjoin= lambda: User.id == UserStrike.user_id, lazy='selectin') + issued_by = relationship(User, primaryjoin= lambda: User.id == UserStrike.issued_by_id, lazy='selectin') # PostUpvote table is at the top !! + diff --git a/freak/rest/__init__.py b/freak/rest/__init__.py index 3e3013c..fc1ded8 100644 --- a/freak/rest/__init__.py +++ b/freak/rest/__init__.py @@ -1,51 +1,472 @@ +from __future__ import annotations +import datetime +import sys +from typing import Iterable, TypeVar +import logging -from flask import Blueprint -from flask_restx import Resource, Api +from quart import render_template, session +from quart import abort, Blueprint, redirect, request, url_for +from pydantic import BaseModel, Field +from quart_auth import current_user, login_required, login_user, logout_user +from quart_schema import validate_request +from quart_wtf.csrf import generate_csrf +from sqlalchemy import delete, insert, select +from suou import Snowflake, deprecated, makelist, not_implemented, want_isodate -from freak.iding import id_to_b32l +from suou.classtools import MISSING, MissingType +from werkzeug.security import check_password_hash +from suou.quart import add_rest -from ..models import Post, User, db +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 -rest_bp = Blueprint('rest', __name__, url_prefix='/v1') -rest = Api(rest_bp) +from ..models import Comment, Guild, Post, PostUpvote, User, db +from .. import UserLoader, app, app_config, __version__ as freak_version, csrf -@rest.route('/nurupo') -class Nurupo(Resource): - def get(self): - return dict(nurupo='ga') +logger = logging.getLogger(__name__) +_T = TypeVar('_T') + +bp = Blueprint('rest', __name__, url_prefix='/v1') +rest = add_rest(app, '/v1', '/ajax') + +## XXX potential security hole, but needed for REST to work +csrf.exempt(bp) + +current_user: UserLoader + +## TODO deprecate auth_required since it does not work +## will be removed in 0.6 +from suou.flask_sqlalchemy import require_auth +auth_required = deprecated('use login_required() and current_user instead')(require_auth(User, db)) + +@not_implemented() +async def authenticated(): + pass + +@bp.get('/nurupo') +async def get_nurupo(): + return dict(ga=-1) + +@bp.get('/health') +async def health(): + async with db as session: + hi = dict( + version=freak_version, + name = app_config.app_name, + post_count = await Post.count(), + user_count = await User.active_count(), + me = Snowflake(current_user.id).to_b32l() if current_user else None, + color_theme = current_user.color_theme if current_user else 0 + ) + + return hi + +@bp.get('/oath') +async def oath(): + try: + ## pull csrf token from session + csrf_tok = session['csrf_token'] + except Exception as e: + try: + logger.warning('CSRF token regenerated!') + csrf_tok = session['csrf_token'] = generate_csrf() + except Exception as e2: + print(e, e2) + abort(503, "csrf_token is null") + + return dict( + ## XXX might break any time! + csrf_token= csrf_tok + ) ## TODO coverage of REST is still partial, but it's planned ## to get complete sooner or later -@rest.route('/user/') -class UserInfo(Resource): - def get(self, id: int): - u: User | None = db.session.execute(db.select(User).where(User.id == id)).scalar() - if u is None: - return dict(error='User not found'), 404 - uj = dict( - id = id_to_b32l(u.id), +## 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. +## Auth-based REST endpoints won't be fully functional until 0.6 in most cases + +## USERS ## + +@bp.get('/user/@me') +@login_required +async def get_user_me(): + return redirect(url_for(f'rest.user_get', id=current_user.id)), 302 + +def _user_info(u: User): + return dict( + id = f'{Snowflake(u.id):l}', username = u.username, display_name = u.display_name, - joined_at = u.joined_at.isoformat('T'), + joined_at = want_isodate(u.joined_at), karma = u.karma, - age = u.age() + age = u.age(), + biography=u.biography, + badges = u.badges() ) - return dict(users={id_to_b32l(id): uj}) -@rest.route('/post/') -class SinglePost(Resource): - def get(self, id: int): - p: Post | None = db.session.execute(db.select(Post).where(Post.id == id)).scalar() +@bp.get('/user/') +async def user_get(id: int): + ## TODO sanizize REST to make blocked users inaccessible + async with db as session: + u: User | None = (await session.execute(select(User).where(User.id == id))).scalar() + if u is None: + return dict(error='User not found'), 404 + uj = _user_info(u) + return dict(users={f'{Snowflake(id):l}': uj}) + +@bp.get('/user//feed') +async def user_feed_get(id: int): + async with db as session: + u: User | None = (await session.execute(select(User).where(User.id == id))).scalar() + if u is None: + return dict(error='User not found'), 404 + uj = _user_info(u) + + feed = [] + algo = user_timeline(u) + posts = await db.paginate(algo) + async for p in posts: + feed.append(await p.feed_info_counts()) + + return dict(users={f'{Snowflake(id):l}': uj}, feed=feed) + +@bp.get('/user/@') +async def resolve_user(username: str): + async with db as session: + uid: User | None = (await session.execute(select(User.id).select_from(User).where(User.username == username))).scalar() + if uid is None: + abort(404, 'User not found') + return redirect(url_for('rest.user_get', id=uid)), 302 + +@bp.get('/user/@/feed') +async def resolve_user_feed(username: str): + async with db as session: + uid: User | None = (await session.execute(select(User.id).select_from(User).where(User.username == username))).scalar() + if uid is None: + abort(404, 'User not found') + return redirect(url_for('rest.user_feed_get', id=uid)), 302 + +## POSTS ## + +@bp.get('/post/') +async def get_post(id: int): + async with db as session: + p: Post | None = (await session.execute(select(Post).where(Post.id == id))).scalar() if p is None: return dict(error='Not found'), 404 pj = dict( - id = id_to_b32l(p.id), + id = f'{Snowflake(p.id):l}', title = p.title, author = p.author.simple_info(), - to = p.topic_or_user().handle(), + to = p.topic_or_user().simple_info(typed=True), created_at = p.created_at.isoformat('T') ) - return dict(posts={id_to_b32l(id): pj}) \ No newline at end of file + if p.is_text_post(): + pj['content'] = p.text_content + + pj['comment_count'] = await p.comment_count() + pj['votes'] = await p.upvotes() + pj['my_vote'] = await p.upvoted_by(current_user.user) + + return dict(posts={f'{Snowflake(id):l}': pj}) + +class VoteIn(BaseModel): + vote: int + +@bp.post('/post//upvote') +@validate_request(VoteIn) +async def upvote_post(id: int, data: VoteIn): + async with db as session: + p: Post | None = (await session.execute(select(Post).where(Post.id == id))).scalar() + + if p is None: + return { 'status': 404, 'error': 'Post not found' }, 404 + + cur_score = await p.upvoted_by(current_user.user) + + match (data.vote, cur_score): + case (1, 0) | (1, -1): + await session.execute(delete(PostUpvote).where(PostUpvote.c.post_id == p.id, PostUpvote.c.voter_id == current_user.id, PostUpvote.c.is_downvote == True)) + await session.execute(insert(PostUpvote).values(post_id = p.id, voter_id = current_user.id, is_downvote = False)) + case (0, _): + await session.execute(delete(PostUpvote).where(PostUpvote.c.post_id == p.id, PostUpvote.c.voter_id == current_user.id)) + case (-1, 1) | (-1, 0): + await session.execute(delete(PostUpvote).where(PostUpvote.c.post_id == p.id, PostUpvote.c.voter_id == current_user.id, PostUpvote.c.is_downvote == False)) + await session.execute(insert(PostUpvote).values(post_id = p.id, voter_id = current_user.id, is_downvote = True)) + case (1, 1) | (1, -1): + pass + case _: + await session.rollback() + return { 'status': 400, 'error': 'Invalid score' }, 400 + + await session.commit() + return { 'votes': await p.upvotes() } + +## COMMENTS ## + +@bp.get('/post//comments') +async def post_comments (id: int): + async with db as session: + p: Post | None = (await session.execute(select(Post).where(Post.id == id))).scalar() + + if p is None: + return { 'status': 404, 'error': 'Post not found' }, 404 + + l = [] + for com in await p.top_level_comments(): + com: Comment + l.append(await com.section_info()) + + return dict(has=l) + + + +## GUILDS ## + +async def _guild_info(gu: Guild): + return dict( + id = f'{Snowflake(gu.id):l}', + name = gu.name, + display_name = gu.display_name, + description = gu.description, + created_at = want_isodate(gu.created_at), + badges = [] + ) + +@bp.get('/guild/') +async def guild_info_id(gid: int): + async with db as session: + gu: Guild | None = (await session.execute(select(Guild).where(Guild.id == gid))).scalar() + + if gu is None: + return dict(error='Not found'), 404 + gj = await _guild_info(gu) + + return dict(guilds={f'{Snowflake(gu.id):l}': gj}) + +@bp.get('/guild/@') +async def guild_info_only(gname: str): + async with db as session: + gu: Guild | None = (await session.execute(select(Guild).where(Guild.name == gname))).scalar() + + if gu is None: + return dict(error='Not found'), 404 + gj = await _guild_info(gu) + + return dict(guilds={f'{Snowflake(gu.id):l}': gj}) + + +@bp.get('/guild/@/feed') +async def guild_feed(gname: str): + async with db as session: + gu: Guild | None = (await session.execute(select(Guild).where(Guild.name == gname))).scalar() + + if gu is None: + return dict(error='Not found'), 404 + gj = await _guild_info(gu) + feed = [] + algo = topic_timeline(gname) + posts = await db.paginate(algo) + async for p in posts: + feed.append(await p.feed_info_counts()) + + return dict(guilds={f'{Snowflake(gu.id):l}': gj}, feed=feed) + + +## CREATE ## + +class CreateIn(BaseModel): + title: str + content: str + privacy: int = Field(default=0, ge=0, lt=4) + +@bp.post('/guild/@') +@login_required +@validate_request(CreateIn) +async def guild_post(data: CreateIn, gname: str): + async with db as session: + user = current_user.user + gu: Guild | None = (await session.execute(select(Guild).where(Guild.name == gname))).scalar() + + if gu is None: + return dict(error='Not found'), 404 + if await gu.has_exiled(current_user.user): + return dict(error=f'You are banned from +{gname}'), 403 + if not await gu.allows_posting(current_user.user): + return dict(error=f'You can\'t post on +{gname}'), 403 + + try: + new_post_id: int = (await session.execute(insert(Post).values( + author_id = user.id, + topic_id = gu.id, + privacy = data.privacy, + title = data.title, + text_content = data.text + ).returning(Post.id))).scalar() + + session.commit() + return dict(id=Snowflake(new_post_id).to_b32l()), 200 + except Exception: + sys.excepthook(*sys.exc_info()) + return {'error': 'Internal Server Error'}, 500 + +## LOGIN/OUT ## + +class LoginIn(BaseModel): + username: str + password: str + remember: bool = False + +@bp.post('/login') +@validate_request(LoginIn) +async def login(data: LoginIn): + async with db as session: + u = (await session.execute(select(User).where(User.username == data.username))).scalar() + match check_login(u, data.password): + case LoginStatus.SUCCESS: + remember_for = int(data.remember) + if remember_for > 0: + login_user(UserLoader(u.get_id()), remember=True) + else: + login_user(UserLoader(u.get_id())) + return {'id': f'{Snowflake(u.id):l}'}, 200 + case LoginStatus.ERROR: + abort(404, 'Invalid username or password') + case LoginStatus.SUSPENDED: + abort(403, 'Your account is suspended') + case LoginStatus.PASS_EXPIRED: + abort(403, 'You need to reset your password following the procedure.') + + +@bp.post('/logout') +@login_required +async def logout(): + logout_user() + return '', 204 + + +## HOME ## + +@bp.get('/home/feed') +@login_required +async def home_feed(): + async with db as session: + me = current_user.user + posts = await db.paginate(public_timeline()) + feed = [] + async for post in posts: + feed.append(await post.feed_info_counts()) + + return dict(feed=feed) + + +@bp.get('/top/guilds') +async def top_guilds(): + async with db as session: + top_g = [await x.sub_info() for x in + (await session.execute(top_guilds_query().limit(10))).scalars()] + + return dict(has=top_g) + +## SEARCH ## + +class QueryIn(BaseModel): + query: str + +@bp.post('/search/top') +@validate_request(QueryIn) +async def search_top(data: QueryIn): + async with db as session: + sq = SearchQuery(data.query) + + result = (await session.execute(sq.select(Post, [Post.title]).limit(20))).scalars() + + return dict(has = [p.feed_info() for p in result]) + + +## SUGGEST + + +@bp.post("/suggest/guild") +@validate_request(QueryIn) +async def suggest_guild(data: QueryIn): + if not data.query.isidentifier(): + return dict(has=[]) + async with db as session: + sq = select(Guild).where(Guild.name.like(data.query + "%")) + + result: Iterable[Guild] = (await session.execute(sq.limit(10))).scalars() + + return dict(has = [g.simple_info() for g in result if await g.allows_posting(current_user.user)]) + + +## SETTINGS + +@bp.get("/settings/appearance") +@login_required +async def get_settings_appearance(): + return dict( + color_theme = current_user.user.color_theme + ) + + +class SettingsAppearanceIn(BaseModel): + color_theme : int | None = None + color_scheme : int | None = None + + +def _missing_or(obj: _T | MissingType, obj2: _T) -> _T: + if obj is None: + return obj2 + return obj + +@bp.patch("/settings/appearance") +@login_required +@validate_request(SettingsAppearanceIn) +async def patch_settings_appearance(data: SettingsIn): + u = current_user.user + if u is None: + abort(401) + + u.color_theme = ( + _missing_or(data.color_theme, u.color_theme % (1 << 8)) % 256 + + _missing_or(data.color_scheme, u.color_theme >> 8) << 8 + ) + current_user.session.add(u) + await current_user.session.commit() + + return '', 204 + +## TERMS + +@bp.get('/about/about') +async def about_about(): + return dict( + content=await render_template("about.md", + quart_version=quart_version, + sa_version=sa_version, + python_version=sys.version.split()[0] + ) + ) + +@bp.get('/about/terms') +async def terms(): + return dict( + content=await render_template("terms.md") + ) + +@bp.get('/about/privacy') +async def privacy(): + return dict( + content=await render_template("privacy.md") + ) + +@bp.get('/about/rules') +async def rules(): + return dict( + content=await render_template("rules.md") + ) diff --git a/freak/search.py b/freak/search.py index b1f46f6..6b357be 100644 --- a/freak/search.py +++ b/freak/search.py @@ -4,7 +4,6 @@ from typing import Iterable from sqlalchemy import Column, Select, select, or_ - class SearchQuery: keywords: Iterable[str] @@ -23,3 +22,4 @@ class SearchQuery: sq = sq.where(or_(*or_cond) if len(or_cond) > 1 else or_cond[0]) return sq + diff --git a/freak/static/admin/style.css b/freak/static/admin/style.css new file mode 100644 index 0000000..990a732 --- /dev/null +++ b/freak/static/admin/style.css @@ -0,0 +1,652 @@ +/** + Static version of style.css from v0.4.0 + expressly for admin pages, skimmed + */ + + @charset "UTF-8"; +* { + box-sizing: border-box; } + +:root { + --c0-accent: #ff7300; + --c1-accent: #ff7300; + --c2-accent: #f837ce; + --c3-accent: #38b8ff; + --c4-accent: #ffe338; + --c5-accent: #78f038; + --c6-accent: #ff9aae; + --c7-accent: #606080; + --c8-accent: #aeaac0; + --c9-accent: #3ae0b8; + --c10-accent: #8828ea; + --c11-accent: #1871d8; + --c12-accent: #885a18; + --c13-accent: #38a856; + --c14-accent: #ff3018; + --c15-accent: #ff1668; + --light-text-primary: #181818; + --light-text-alt: #444; + --light-border: #999; + --light-success: #73af00; + --light-error: #e04830; + --light-warning: #dea800; + --light-canvas: #eaecee; + --light-background: #f9f9f9; + --light-bg-sharp: #fdfdff; + --dark-text-primary: #e8e8e8; + --dark-text-alt: #c0cad3; + --dark-border: #777; + --dark-success: #93cf00; + --dark-error: #e04830; + --dark-warning: #dea800; + --dark-canvas: #0a0a0e; + --dark-background: #181a21; + --dark-bg-sharp: #080808; + --accent: var(--c0-accent); + --light-accent: var(--accent); + --dark-accent: var(--accent); + --text-primary: var(--light-text-primary); + --text-alt: var(--light-text-alt); + --border: var(--light-border); + --success: var(--light-success); + --error: var(--light-error); + --warning: var(--light-warning); + --canvas: var(--light-canvas); + --background: var(--light-background); + --bg-sharp: var(--light-bg-sharp); } + +@media (prefers-color-scheme: dark) { + :root { + --text-primary: var(--dark-text-primary); + --text-alt: var(--dark-text-alt); + --border: var(--dark-border); + --success: var(--dark-success); + --error: var(--dark-error); + --warning: var(--dark-warning); + --canvas: var(--dark-canvas); + --background: var(--dark-background); + --bg-sharp: var(--dark-bg-sharp); } } + +.color-scheme-light { + --text-primary: var(--light-text-primary); + --text-alt: var(--light-text-alt); + --border: var(--light-border); + --success: var(--light-success); + --error: var(--light-error); + --warning: var(--light-warning); + --canvas: var(--light-canvas); + --background: var(--light-background); + --bg-sharp: var(--light-bg-sharp); } + +.color-scheme-dark { + --text-primary: var(--dark-text-primary); + --text-alt: var(--dark-text-alt); + --border: var(--dark-border); + --success: var(--dark-success); + --error: var(--dark-error); + --warning: var(--dark-warning); + --canvas: var(--dark-canvas); + --background: var(--dark-background); + --bg-sharp: var(--dark-bg-sharp); } + +.color-theme-1 { + --accent: var(--c1-accent); } + +.color-theme-2 { + --accent: var(--c2-accent); } + +.color-theme-3 { + --accent: var(--c3-accent); } + +.color-theme-4 { + --accent: var(--c4-accent); } + +.color-theme-5 { + --accent: var(--c5-accent); } + +.color-theme-6 { + --accent: var(--c6-accent); } + +.color-theme-7 { + --accent: var(--c7-accent); } + +.color-theme-8 { + --accent: var(--c8-accent); } + +.color-theme-9 { + --accent: var(--c9-accent); } + +.color-theme-10 { + --accent: var(--c10-accent); } + +.color-theme-11 { + --accent: var(--c11-accent); } + +.color-theme-12 { + --accent: var(--c12-accent); } + +.color-theme-13 { + --accent: var(--c13-accent); } + +.color-theme-14 { + --accent: var(--c14-accent); } + +.color-theme-15 { + --accent: var(--c15-accent); } + +body, input, select, button { + font-family: system-ui, -apple-system, BlinkMacSystemFont, "Noto Sans", sans-serif; } + +body { + line-height: 1.5; + font-size: 18px; } + +input, button, select { + font-family: inherit; + font-size: inherit; + line-height: inherit; } + +textarea { + font-family: monospace; } + +input:not([type="submit"], [type="button"], [type="reset"]), textarea { + background: var(--bg-sharp); + color: var(--text-main); + border: var(--border); + border-radius: 9px; } + +body { + color: var(--text-primary); + background-color: var(--canvas); } + +.card { + background-color: var(--background); + border: var(--canvas) 1px solid; + border-radius: 9px; + margin: 12px auto; + padding: 12px; + max-width: 960px; } + +.centered { + text-align: center; + font-size: 110%; } + +a:link, a:visited { + color: var(--accent); + transition: ease 5s; } + +img { + max-width: 100%; + max-height: 100vh; } + +.faint { + opacity: .75; } + strong .faint { + font-weight: 400; } + +.callout { + color: var(--text-alt); } + +.success { + color: var(--success); } + +.error { + color: var(--error); } + +.warning { + color: var(--warning); } + +body { + margin: 0; } + +.content-container { + display: flex; + flex-direction: row-reverse; + align-items: start; + justify-content: flex-start; } + +.content-nav { + width: 320px; + font-size: smaller; } + +.content-main { + flex: 1; } + +main { + min-height: 70vh; + margin: 12px auto; } + +header.header { + background-color: var(--background); + display: flex; + justify-content: space-between; + overflow: hidden; + height: 3em; + padding: .75em 1.5em; + line-height: 1; } + header.header h1 { + margin: 0; + padding: 0; + font-size: 1.5em; } + header.header .metanav { + align-self: flex-end; + font-size: 1.5em; + margin: auto; + margin-inline-start: 2em; } + header.header .metanav ul { + list-style: none; + padding: 0; + margin: 0; } + header.header .metanav ul > li { + margin: 0 6px; } + header.header .metanav ul, header.header .metanav ul > li { + display: flex; + flex-direction: row; + align-items: center; + justify-content: flex-end; } + header.header .metanav, header.header .metanav > ul, header.header .metanav > ul > li:has(.mini-search-bar) { + flex: 1; } + header.header .metanav ul > li span { + color: var(--text-primary); + font-size: .6em; } + header.header .header-username > * { + display: block; + font-size: .5em; + line-height: 1.25; } + header.header .header-username .icon { + font-size: inherit; } + header.header a { + text-decoration: none; } + header.header .mini-search-bar { + display: flex; + flex-direction: row; + align-items: center; + justify-content: flex-end; + flex: 1; + font-size: 1.2rem; } + header.header .mini-search-bar [type="search"] { + flex: 1; + border-radius: 0; + border: 0; + border-bottom: 2px solid var(--border); + background-color: inherit; + focus-background-color: var(--bg-sharp); + focus-border-color: var(--accent); } + header.header .mini-search-bar [type="submit"] { + height: 0; + width: 0; + padding: 0; + margin: 0; + border-radius: 0; + opacity: 0; + overflow: hidden; } + header.header .mini-search-bar + a { + display: none; } + +aside.card { + overflow: hidden; } + aside.card > :is(h1, h2, h3, h4, h5, h6):first-child { + background-color: var(--accent); + padding: 6px 12px; + margin: -12px -12px 0 -12px; + position: relative; } + aside.card > :is(h1, h2, h3, h4, h5, h6):first-child a { + color: inherit; + text-decoration: underline; } + aside.card > ul { + list-style: none; + margin: 0; + padding: 0; } + aside.card > ul > li { + border-bottom: 1px solid var(--canvas); + padding: 12px; } + aside.card > ul > li:last-child { + border-bottom: none; } + aside.card > p { + padding: 12px; + margin: 0; } + +.flash { + border-color: yellow; + background-color: #fff00040; } + +ul.timeline { + list-style: none; + padding: 0 1em; } + ul.timeline > li { + border-bottom: 1px solid var(--border); + margin-bottom: 6px; } + ul.timeline > li:last-child { + border-bottom: 0; + margin-bottom: 0; } + +ul.inline { + list-style: none; + padding: 0; + margin: 0; + display: inline; } + ul.inline > li { + display: inline; } + ul.inline > li::before { + content: ' · '; + margin: 0 .5em; } + ul.inline > li:first-child::before { + content: ''; + margin: 0; } + +ul.grid { + list-style: none; + padding: 0; + display: grid; + grid-template-columns: 1fr 1fr 1fr 1fr; + grid-template-rows: auto; } + ul.grid > li { + border: 1px solid var(--border); + border-radius: .5em; + padding: .5em; + margin: 1em .5em; + text-align: center; } + ul.grid > li small { + display: block; } + +ul.message-options { + color: var(--text-alt); + list-style: none; + padding: 0; + font-size: smaller; } + +.post-frame { + margin-left: 3em; + position: relative; + min-height: 6em; + clear: right; } + [dir="rtl"] .post-frame { + margin-left: 0; + margin-right: 3em; } + .post-frame .message-options { + margin-bottom: 1em; } + .post-frame .message-stats { + position: absolute; + left: -3em; + top: 0; + display: flex; + flex-direction: column; + width: 2em; + text-align: center; + line-height: 1.0; } + [dir="rtl"] .post-frame .message-stats { + right: -3em; + left: unset; } + .post-frame .message-stats > * { + display: flex; + flex-direction: column; } + .post-frame .message-stats strong { + font-size: smaller; } + .post-frame .message-stats a { + text-decoration: none; + margin: .25em 0; } + +.message-meta { + font-size: smaller; + color: var(--text-alt); } + +.shorten { + max-height: 18em; + overflow-y: hidden; + position: relative; } + .shorten::after { + content: ''; + position: absolute; + z-index: 10; + top: 16em; + left: 0; + width: 100%; + height: 2em; + display: block; + background: linear-gradient(to bottom, rgba(0, 0, 0, 0) 0%, var(--background) 100%); } + +.comments-button .comment-count { + display: inline-block; + min-width: 1em; + text-align: center; } + +i.icon { + font-size: inherit; + font-style: normal; } + +form.boundaryless { + flex: 1; + background: transparent; + color: inherit; + border: 0; + border-top: 1px solid var(--border); + border-bottom: 1px solid var(--border); } + form.boundaryless dd { + display: flex; + flex-direction: row; + width: 100%; + box-sizing: border-box; + margin: 0; } + form.boundaryless textarea, form.boundaryless input[type="text"] { + width: 100%; } + form.boundaryless textarea { + min-height: 4em; } + form.boundaryless p input[type="text"] { + width: unset; } + +.big-search-bar form { + display: flex; + flex-direction: row; + font-size: 1.6em; + width: 80%; + margin: auto; } + .big-search-bar form > [type="search"] { + flex: 1; + border-bottom: 2px solid var(--border); } + +footer.footer { + text-align: center; + font-size: smaller; } + footer.footer ul { + list-style: none; + padding: 0; + margin: 0; } + footer.footer ul > li { + display: inline-block; + margin: 0 2em; } + +textarea.comment-area { + width: 100%; } + +button, [type="submit"], [type="reset"], [type="button"] { + background-color: transparent; + color: var(--accent); + border: 1px solid var(--accent); + border-radius: 6px; + padding: 6px 12px; + margin: 6px; + cursor: pointer; } + button.primary, [type="submit"].primary, [type="reset"].primary, [type="button"].primary { + background-color: var(--accent); + color: var(--background); } + button[disabled], [type="submit"][disabled], [type="reset"][disabled], [type="button"][disabled] { + opacity: .5; + cursor: not-allowed; + border: var(--border); + color: var(--border); } + button.primary[disabled], [type="submit"].primary[disabled], [type="reset"].primary[disabled], [type="button"].primary[disabled] { + color: var(--background); + background-color: var(--border); } + button:first-child, [type="submit"]:first-child, [type="reset"]:first-child, [type="button"]:first-child { + margin-inline-start: 0; } + button:last-child, [type="submit"]:last-child, [type="reset"]:last-child, [type="button"]:last-child { + margin-inline-end: 0; } + +.button-row-right { + display: flex; + flex-direction: row; + justify-content: flex-end; } + +.comment-frame { + border: 1px solid var(--border); + background: var(--background); + padding: 12px 12px 6px; + border-radius: 24px; + border-start-start-radius: 0; + min-width: 50%; + width: 0; + margin-inline-end: auto; + margin-bottom: 12px; + position: relative; } + .comment-frame::before { + content: ''; + border: 1px solid var(--border); + border-inline-end: 0; + border-bottom: 0; + background: var(--background); + height: 1em; + width: 1em; + position: absolute; + left: calc(-1px - .5em); + top: -1px; + transform: skewX(45deg); } + li:has(> .comment-frame) { + list-style: none; } + +.border-accent { + border: var(--accent) 1px solid; + display: inline-flex; + align-items: center; + padding: 0 4px; } + +.round { + border-radius: 1em; } + +.done { + opacity: .5; } + +button.card { + width: 100%; + padding: .5em 1em; + background-color: transparent; + border-color: var(--accent); + color: var(--accent); + border-radius: 1em; } + button.card.primary { + background-color: var(--accent); + color: var(--background); } + +.big_icon { + display: block; + margin: 12px auto; + font-size: 36px; + text-align: center; } + +textarea.create_text { + min-height: 8em; } + form.boundaryless textarea.create_text { + min-height: 8em; } + +:is(input, select, textarea).fullwidth { + width: 100%; + padding: 0; } + +label:has([type="checkbox"]:not(:checked)) { + opacity: .75; } + +.content { + margin: 2em auto; + max-width: 1280px; } + +blockquote { + padding-left: 1em; + border-left: 4px solid var(--border); + margin-left: 0; } + [dir="rtl"] blockquote { + padding-left: 0; + border-left: 0; + padding-right: 1em; + border-right: 4px solid var(--border); } + +.message-content p { + margin: 4px 0; } + +.message-content ul { + margin: 4px 0; + padding: 0; + padding-inline-start: 1.5em; } + .message-content ul > li { + margin: 0; } + +h1, h2, h3, h4, h5, h6 { + font-weight: 500; } + +@media screen and (max-width: 800px) { + .content-container { + display: block; } + .content-nav, .content-main { + width: 100%; } + ul.grid { + grid-template-columns: 1fr 1fr; } + .nomobile { + display: none !important; } + body { + position: relative; } + footer.mobile-nav { + position: sticky; + bottom: 0; + left: 0; + width: 100%; + overflow: hidden; + margin: 0; + padding: 0; + background-color: var(--background); + box-shadow: 0 0 6px var(--border); + z-index: 150; } + footer.mobile-nav > ul { + display: flex; + list-style: none; + margin: 0; + padding: 0; + flex-direction: row; + align-items: stretch; + justify-content: stretch; } + footer.mobile-nav > ul > li { + flex: 1; + padding: .5em; + margin: 0; + text-align: center; } + footer.mobile-nav > ul > li a { + text-decoration: none; } + footer.mobile-nav > ul > li .icon { + font-size: 2rem; } + .content-nav { + margin: 1em; + width: unset; } + header.header h1 { + margin-top: 4px; + margin-left: 6px; } + .content-header { + text-align: center; } + .big-search-bar form { + flex-direction: column; } + .big-search-bar form [type="submit"] { + width: unset; + margin: 12px auto; } } + +@media screen and (max-width: 960px) { + .header-username { + display: none; } + header.header { + padding: .5em .5em; } + header.header .mini-search-bar { + display: none; } + header.header .mini-search-bar + a { + display: inline-block; } + header.header ul > li:has(.mini-search-bar) { + flex: unset; } } + +@media screen and (min-width: 801px) { + .mobileonly { + display: none !important; } } + diff --git a/freak/static/js/lib.js b/freak/static/js/lib.js index 7bebaed..a9d7557 100644 --- a/freak/static/js/lib.js +++ b/freak/static/js/lib.js @@ -1,14 +1,6 @@ (function(){ - // UNUSED! Period is disallowed regardless now - function checkUsername(u){ - return ( - /^\./.test(u)? 'You cannot start username with a period.': - /\.$/.test(u)? 'You cannot end username with a period.': - /\.\./.test(u)? 'You cannot have more than one period in a row.': - u.match(/\.(com|net|org|txt)$/)? 'Your username cannot end with .' + forbidden_extensions[1]: - 'ok' - ); - } + "use strict"; + function attachUsernameInput(){ @@ -35,21 +27,30 @@ usernameInputMessage.className = 'username-input-message error'; return; } + if (value.length >= 100) { + usernameInputMessage.innerHTML = 'Your username must be shorter.'; + usernameInputMessage.className = 'username-input-message error'; + return; + } if(/^[01]/.test(value)) { usernameInputMessage.innerHTML = 'Your username cannot start with 0 or 1.'; usernameInputMessage.className = 'username-input-message error'; return; } usernameInputMessage.innerHTML = 'Checking username...'; - usernameInputMessage.className = 'username-input-message checking'; + usernameInputMessage.className = 'username-input-message checking faint'; requestUsernameAvailability(value, endpoint).then((resp) => { if (['ok', void 0].indexOf(resp.status) < 0){ usernameInputMessage.innerHTML = 'Sorry, there was an unknown error.'; usernameInputMessage.className = 'username-input-message error'; return; } - if (resp.is_available){ - usernameInputMessage.innerHTML = "The username @" + value + " is available!"; + if (resp.is_valid === false) { + usernameInputMessage.innerHTML = "You can't use this username."; + usernameInputMessage.className = 'username-input-message error'; + return; + } else if (resp.is_available){ + usernameInputMessage.innerHTML = `The username @${value} is available!`; usernameInputMessage.className = 'username-input-message success'; return; } else { @@ -140,9 +141,36 @@ }).then(e => e.json()); } + function enableThemeChange() { + let schemeItems = document.querySelectorAll('.apply-theme [name="color_scheme"]'); + + for (let ii of schemeItems) { + ii.addEventListener('change', function(e) { + let removed_classes = Array.from(document.body.classList).filter((x) => /^color-scheme-/.test(x)); + document.body.classList.remove(...removed_classes); + if (e.target.value !== 'unset') { + document.body.classList.add(`color-scheme-${e.target.value}`); + } + console.log(`Color scheme changed to ${e.target.value}`) + }) + } + + let themeItems = document.querySelectorAll('.apply-theme [name="color_theme"]'); + + for (let ii of themeItems) { + ii.addEventListener('change', function(e) { + let removed_classes = Array.from(document.body.classList).filter((x) => /^color-theme-/.test(x)); + document.body.classList.remove(...removed_classes); + document.body.classList.add(`color-theme-${e.target.value}`); + console.log(`Color theme changed to ${e.target.value}`) + }) + } + } + function main() { attachUsernameInput(); enablePostVotes(); + enableThemeChange(); } main(); diff --git a/freak/static/sass/base.sass b/freak/static/sass/base.sass index 97b65a6..9289b5f 100644 --- a/freak/static/sass/base.sass +++ b/freak/static/sass/base.sass @@ -5,13 +5,29 @@ box-sizing: border-box \:root - --accent: #ff7300 + --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: #e04433 + --light-error: #e04830 + --light-warning: #dea800 --light-canvas: #eaecee --light-background: #f9f9f9 --light-bg-sharp: #fdfdff @@ -20,11 +36,14 @@ --dark-text-alt: #c0cad3 --dark-border: #777 --dark-success: #93cf00 - --dark-error: #e04433 + --dark-error: #e04830 + --dark-warning: #dea800 --dark-canvas: #0a0a0e --dark-background: #181a21 --dark-bg-sharp: #080808 + --accent: var(--c0-accent) + // the following are DEPRECATED // --light-accent: var(--accent) --dark-accent: var(--accent) @@ -34,6 +53,7 @@ --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) @@ -45,30 +65,78 @@ --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) -body.color-scheme-light +.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) -body.color-scheme-dark +.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: $ui-fonts @@ -103,12 +171,6 @@ body padding: 12px max-width: 960px -.a11y - overflow: hidden - width: 0 - height: 0 - display: inline-block - .centered text-align: center font-size: 110% @@ -123,4 +185,18 @@ img max-height: 100vh .faint - opacity: .75 \ No newline at end of file + opacity: .75 + strong & + font-weight: 400 + +.callout + color: var(--text-alt) + +.success + color: var(--success) + +.error + color: var(--error) + +.warning + color: var(--warning) diff --git a/freak/static/sass/content.sass b/freak/static/sass/content.sass index 56354ec..6aaf1fa 100644 --- a/freak/static/sass/content.sass +++ b/freak/static/sass/content.sass @@ -14,15 +14,6 @@ blockquote padding-right: 1em border-right: 4px solid var(--border) -.success - color: var(--success) - -.error - color: var(--error) - -.callout - color: var(--text-alt) - .message-content p margin: 4px 0 diff --git a/freak/static/sass/layout.sass b/freak/static/sass/layout.sass index b933e36..a2c70eb 100644 --- a/freak/static/sass/layout.sass +++ b/freak/static/sass/layout.sass @@ -1,7 +1,9 @@ @import "constants.sass" - +body + margin: 0 + .content-container display: flex flex-direction: row-reverse @@ -18,6 +20,7 @@ main min-height: 70vh + margin: 12px auto // __ header styles __ // @@ -28,7 +31,6 @@ header.header overflow: hidden height: 3em padding: .75em 1.5em - margin: -12px line-height: 1 h1 margin: 0 @@ -54,6 +56,11 @@ header.header &, > ul, > ul > li:has(.mini-search-bar) flex: 1 + + ul > li span + color: var(--text-primary) + font-size: .6em + .header-username > * display: block @@ -98,9 +105,9 @@ header.header // __ aside styles __ // aside.card overflow: hidden - > :first-child + > :is(h1, h2, h3, h4, h5, h6):first-child background-color: var(--accent) - padding: 12px + padding: 6px 12px margin: -12px -12px 0 -12px position: relative a @@ -115,6 +122,9 @@ aside.card padding: 12px &:last-child border-bottom: none + > p + padding: 12px + margin: 0 .flash @@ -135,6 +145,7 @@ ul.inline list-style: none padding: 0 margin: 0 + display: inline > li display: inline &::before @@ -142,14 +153,29 @@ ul.inline margin: 0 .5em &: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 + > li + border: 1px solid var(--border) + border-radius: .5em + padding: .5em + margin: 1em .5em + text-align: center + small + display: block + ul.message-options color: var(--text-alt) list-style: none padding: 0 font-size: smaller - .comment-frame & - margin-bottom: -4px .post-frame margin-left: 3em @@ -279,11 +305,17 @@ button, [type="submit"], [type="reset"], [type="button"] &.primary background-color: var(--accent) - color: var(--bg-main) + color: var(--background) &[disabled] opacity: .5 cursor: not-allowed + border: var(--border) + color: var(--border) + + &.primary[disabled] + color: var(--background) + background-color: var(--border) &:first-child margin-inline-start: 0 @@ -298,12 +330,74 @@ button, [type="submit"], [type="reset"], [type="button"] .comment-frame border: 1px solid var(--border) - padding: 12px + background: var(--background) + padding: 12px 12px 6px border-radius: 24px border-start-start-radius: 0 min-width: 50% width: 0 - margin-right: auto + margin-inline-end: auto + margin-bottom: 12px + position: relative + &::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(> &) + 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 + + &.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 + + // specificity ew // + form.boundaryless & + min-height: 8em + +\:is(input, select, textarea).fullwidth + width: 100% + padding: 0 + + +label:has([type="checkbox"]:not(:checked)) + opacity: .75 diff --git a/freak/static/sass/mobile.sass b/freak/static/sass/mobile.sass index 25a8e02..4d45ce0 100644 --- a/freak/static/sass/mobile.sass +++ b/freak/static/sass/mobile.sass @@ -6,11 +6,70 @@ .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 + + > ul + display: flex + list-style: none + margin: 0 + padding: 0 + flex-direction: row + align-items: stretch + justify-content: stretch + > li + flex: 1 + padding: .5em + margin: 0 + text-align: center + a + text-decoration: none + .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 + + [type="submit"] + width: unset + margin: 12px auto + @media screen and (max-width: 960px) .header-username display: none header.header + padding: .5em .5em + .mini-search-bar display: none @@ -18,4 +77,11 @@ display: inline-block ul > li:has(.mini-search-bar) - flex: unset \ No newline at end of file + flex: unset + + +// not mobile: // + +@media screen and (min-width: 801px) + .mobileonly + display: none !important \ No newline at end of file diff --git a/freak/templates/403.html b/freak/templates/403.html index 0826e46..ee4f511 100644 --- a/freak/templates/403.html +++ b/freak/templates/403.html @@ -1,7 +1,8 @@ {% extends "base.html" %} +{% from "macros/title.html" import title_tag with context %} {% block title %} - X _ X; on {{ app_name }} + {{ title_tag('X _ X') }} {% endblock %} {% block body %} diff --git a/freak/templates/404.html b/freak/templates/404.html index dae2961..4a9f92b 100644 --- a/freak/templates/404.html +++ b/freak/templates/404.html @@ -1,7 +1,8 @@ {% extends "base.html" %} +{% from "macros/title.html" import title_tag with context %} {% block title %} - O _ O; on {{ app_name }} + {{ title_tag('O _ O') }} {% endblock %} {% block body %} diff --git a/freak/templates/405.html b/freak/templates/405.html index c3cde64..02c926b 100644 --- a/freak/templates/405.html +++ b/freak/templates/405.html @@ -1,7 +1,8 @@ {% extends "base.html" %} +{% from "macros/title.html" import title_tag with context %} {% block title %} - O _ O; on {{ app_name }} + {{ title_tag('O _ O') }} {% endblock %} {% block body %} diff --git a/freak/templates/about.html b/freak/templates/about.html index b7be8b7..0474652 100644 --- a/freak/templates/about.html +++ b/freak/templates/about.html @@ -9,22 +9,9 @@ {% block content %}
-

Stats

-
    -
  • No. of posts: {{ post_count }}
  • -
  • No. of active users (posters in the last 30 days): {{ user_count }}
  • -
- -

Software versions

-
    -
  • Python: {{ python_version }}
  • -
  • SQLAlchemy: {{ sa_version }}
  • -
  • Flask: {{ flask_version }}
  • -
  • {{ app_name }}: {{ app_version }}
  • -
- -

License

-

Source code is available at: https://github.com/sakuragasaki46/freak

+{% filter to_markdown %} +{% include "about.md" %} +{% endfilter %}
{% endblock %} diff --git a/freak/templates/about.md b/freak/templates/about.md new file mode 100644 index 0000000..a3bd15e --- /dev/null +++ b/freak/templates/about.md @@ -0,0 +1,25 @@ + +## Stats + +* \# of posts: **{{ post_count }}** +* \# of active users (posters in the last 30 days): **{{ user_count }}** + +## Software versions + +* **Python**: {{ python_version }} +* **SQLAlchemy**: {{ sa_version }} +* **Quart**: {{ quart_version }} +* **{{ app_name }}**: {{ app_version }} + +## License + +Source code is available at: + +{% if impressum %} +## Legal Contacts + +``` +{{ impressum }} +``` +{% endif %} + diff --git a/freak/templates/admin/400.html b/freak/templates/admin/400.html new file mode 100644 index 0000000..302b34c --- /dev/null +++ b/freak/templates/admin/400.html @@ -0,0 +1,10 @@ +{% extends "admin/admin_base.html" %} + + +{% block content %} +
+

Bad Request

+ +

Back to homepage.

+
+{% endblock %} diff --git a/freak/templates/admin/403.html b/freak/templates/admin/403.html new file mode 100644 index 0000000..d48cc53 --- /dev/null +++ b/freak/templates/admin/403.html @@ -0,0 +1,12 @@ +{% extends "admin/admin_base.html" %} +{% from "macros/title.html" import title_tag with context %} + + + +{% block content %} +
+

Access Denied

+ +

Back to homepage.

+
+{% endblock %} diff --git a/freak/templates/admin/404.html b/freak/templates/admin/404.html new file mode 100644 index 0000000..b7dd7ff --- /dev/null +++ b/freak/templates/admin/404.html @@ -0,0 +1,12 @@ +{% extends "admin/admin_base.html" %} +{% from "macros/title.html" import title_tag with context %} + + + +{% block content %} +
+

Not Found

+ +

Back

+
+{% endblock %} diff --git a/freak/templates/admin/500.html b/freak/templates/admin/500.html new file mode 100644 index 0000000..23e2f50 --- /dev/null +++ b/freak/templates/admin/500.html @@ -0,0 +1,11 @@ +{% extends "admin/admin_base.html" %} + + +{% block content %} +
+

Internal Server Error

+ +

It's on us. Refresh the page.

+
+{% endblock %} + diff --git a/freak/templates/admin/admin_base.html b/freak/templates/admin/admin_base.html index 11a0306..46d9655 100644 --- a/freak/templates/admin/admin_base.html +++ b/freak/templates/admin/admin_base.html @@ -5,22 +5,24 @@ {{ title_tag("Admin") }} - - + + {% for private_style in private_styles %} + + {% endfor %} - - + +
+

{{ app_name }}: Admin

+
{% for message in get_flashed_messages() %}
{{ message }}
{% endfor %} {% block content %}{% endblock %}
- - + + diff --git a/freak/templates/admin/admin_home.html b/freak/templates/admin/admin_home.html index ad49860..6634f0b 100644 --- a/freak/templates/admin/admin_home.html +++ b/freak/templates/admin/admin_home.html @@ -1,9 +1,15 @@ {% extends "admin/admin_base.html" %} {% block content %} -
    + {% endblock %} \ No newline at end of file diff --git a/freak/templates/admin/admin_report_detail.html b/freak/templates/admin/admin_report_detail.html index 8844c5a..6d14ea2 100644 --- a/freak/templates/admin/admin_report_detail.html +++ b/freak/templates/admin/admin_report_detail.html @@ -1,5 +1,6 @@ {% extends "admin/admin_base.html" %} {% from "macros/embed.html" import embed_post with context %} +{% from "macros/icon.html" import icon, callout with context %} {% block content %}

    Report detail #{{ report.id }}

    @@ -14,10 +15,20 @@ {% else %}

    Unknown media type

    {% endif %} + {% if report.is_critical() %} + {% call callout('nsfw_language') %} + This is a critical offense. “Strike” will immediately suspend the offender's account. + {% endcall %} + {% endif %}
    + {% if report.is_critical() %} + + {% else %} - + + {% endif %} +
    {% endblock %} diff --git a/freak/templates/admin/admin_strikes.html b/freak/templates/admin/admin_strikes.html new file mode 100644 index 0000000..28825cf --- /dev/null +++ b/freak/templates/admin/admin_strikes.html @@ -0,0 +1,20 @@ +{% extends "admin/admin_base.html" %} +{% from "macros/feed.html" import stop_scrolling, no_more_scrolling with context %} + +{% block content %} +
      + {% for strike in strike_list %} +
    • +

      #{{ strike.id | to_cb32 }} to {{ strike.user.handle() }}

      +
        +
      • Reason: {{ report_reasons[strike.reason_code] }}
      • + + + {% endfor %} + {% if strike_list.has_next %} + {{ stop_scrolling(strike_list.page) }} + {% else %} + {{ no_more_scrolling(strike_list.page) }} + {% endif %} +
      +{% endblock %} \ No newline at end of file diff --git a/freak/templates/admin/admin_user_detail.html b/freak/templates/admin/admin_user_detail.html new file mode 100644 index 0000000..36f8cb3 --- /dev/null +++ b/freak/templates/admin/admin_user_detail.html @@ -0,0 +1,62 @@ +{% extends "admin/admin_base.html" %} +{% from "macros/icon.html" import callout with context %} + +{% block content %} +

      User: {{ u.handle() }}

      + +
        +
      • Age: {{ u.age() }} years old ({{ u.gdpr_birthday.strftime("%B %d, %Y") }})
      • +
      • Registered at: {{ u.joined_at.strftime("%B %d, %Y %H:%M %z") }}
      • +
      • Registered from IP address: {{ u.joined_ip }}
      • +
      • Status: {{ account_status_string(u) }}
      • +
      • Karma: {{ u.karma }}
      • + {% if u.email %} +
      • E-mail: {{ u.email }}
      • + {% endif %} +
      + +{% if u.banned_at %} +{% call callout('spoiler', 'error') %} +{{ u.handle() }} is suspended +{% if u.banned_until %}until {{ u.banned_until.strftime("%B %d, %Y %H:%M %z") }}{% else %}indefinitely{% endif %}. +{% if u.banned_message %}
      Ban message: “{{ u.banned_message }}”{% endif %} +{% endcall %} +{% endif %} + + +

      Quick Actions

      +
      + + +
      + {% if u.banned_at %} + + {% else %} + + + {% endif %} +
      + +

      Strikes

      + +{% if strikes %} +
        + {% for strike in strikes %} +
      • +

        #{{ strike.id | to_cb32 }}

        +
          +
        • Reason: {{ report_reasons[strike.reason_code] }}
        • +
        • {{ strike.text_content }}
        • + + {% endfor %} +
        +{% else %} +

        {{ u.handle() }} is all good!

        +{% endif %} +{% endblock %} \ No newline at end of file diff --git a/freak/templates/admin/admin_users.html b/freak/templates/admin/admin_users.html new file mode 100644 index 0000000..d55d9f8 --- /dev/null +++ b/freak/templates/admin/admin_users.html @@ -0,0 +1,30 @@ +{% extends "admin/admin_base.html" %} +{% from "macros/feed.html" import stop_scrolling, no_more_scrolling with context %} + +{% block content %} +
          + {% for u in user_list %} +
        • +

          {{ u.handle() }} (#{{ u.id | to_b32l }}) + {%- if u.is_administrator %} + (Admin) + {% endif -%} + {% if u == current_user.user %} + (You) + {% endif -%} +

          +
            +
          • Age: {{ u.age() }} years old ({{ u.gdpr_birthday.strftime("%B %d, %Y") }})
          • +
          • Registered at: {{ u.joined_at.strftime("%B %d, %Y %H:%M %z") }}
          • +
          • Registered from IP address: {{ u.joined_ip }}
          • +
          • Status: {{ account_status_string(u) }}
          • +
          +
        • + {% endfor %} + {% if user_list.has_next %} + {{ stop_scrolling(user_list.page) }} + {% else %} + {{ no_more_scrolling(user_list.page) }} + {% endif %} +
        +{% endblock %} \ No newline at end of file diff --git a/freak/templates/base.html b/freak/templates/base.html index 0038452..96a9916 100644 --- a/freak/templates/base.html +++ b/freak/templates/base.html @@ -2,8 +2,8 @@ - + {% from "macros/icon.html" import icon with context %} {% block title %} {{ app_name }} {% endblock %} @@ -12,8 +12,10 @@ This Service is available "AS IS", with NO WARRANTY, explicit or implied. Sakuragasaki46 is NOT legally liable for Your use of the Service. This service is age-restricted; do not access if underage. - More info: https://{{ domain_name }}/terms + More info: https://{{ server_name }}/terms --> + + {# psa: icons url MUST be supplied by .env via PRIVATE_ASSETS= #} @@ -23,7 +25,7 @@ - +

        {{ app_name }}

      • {% endif %} {% if g.no_user %} - {% elif current_user.is_authenticated %} -
      • - - create -
      • - - profile -
      • -
        - @{{ current_user.username }} - {{ current_user.karma }} karma -
        + {% elif current_user %} +
      • + + {{ icon('add') }} + New post + +
      • + {% if current_user.is_administrator %} +
      • + + {{ icon('mod') }} +
      • -
      • - log out + {% endif %} +
      • {{ icon('profile') }} +
        + {{ current_user.handle() }} + {{ icon('karma') }} {{ current_user.karma }} karma +
      • +
      • + {{ icon('logout') }}
      • {% else %} -
      • - - log in -
      • - - register -
      • +
      • + {{ icon('logout') }} +
      • +
      • + {{ icon('join') }} +
      • {% endif %}
      @@ -75,6 +81,7 @@ {% for message in get_flashed_messages() %}
      {{ message }}
      {% endfor %} + {% block body %}
      {% block heading %}{% endblock %} @@ -98,13 +105,24 @@
    • GitHub
    + {% if current_user %} + + {% endif %}