add dependency on libsuou, add settings page, color themes, strikes, blocks and memberships (the latter two to be implemented later)

This commit is contained in:
Yusur 2025-06-19 01:29:40 +02:00
parent c451a15b1c
commit c1c005cc4e
40 changed files with 992 additions and 260 deletions

View file

@ -1,5 +1,12 @@
# Changelog # Changelog
## 0.4.0
- Added dependency to [SUOU](https://github.com/sakuragasaki46/suou)
- Added user strikes, memberships and user blocks
- Added ✨color themes✨
- Users can now set their display name and biography in `/settings`
## 0.3.3 ## 0.3.3
- Fixed bugs in templates introduced in 0.3.2 - Fixed bugs in templates introduced in 0.3.2

View file

@ -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 ###

View file

@ -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

View file

@ -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 ###

View file

@ -1,9 +1,11 @@
import re
from sqlite3 import ProgrammingError from sqlite3 import ProgrammingError
from typing import Any
import warnings import warnings
from flask import ( from flask import (
Flask, g, redirect, render_template, Flask, g, render_template,
request, send_from_directory, url_for request, send_from_directory, url_for
) )
import os import os
@ -11,23 +13,38 @@ import dotenv
from flask_login import LoginManager from flask_login import LoginManager
from flask_wtf.csrf import CSRFProtect from flask_wtf.csrf import CSRFProtect
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.exc import SQLAlchemyError
from suou import Snowflake, ssv_list
from werkzeug.routing import BaseConverter from werkzeug.routing import BaseConverter
from sassutils.wsgi import SassMiddleware from sassutils.wsgi import SassMiddleware
__version__ = '0.3.3' from suou.configparse import ConfigOptions, ConfigValue
from freak.colors import color_themes, theme_classes
__version__ = '0.4.0-dev24'
APP_BASE_DIR = os.path.dirname(os.path.dirname(__file__)) APP_BASE_DIR = os.path.dirname(os.path.dirname(__file__))
if not dotenv.load_dotenv(): if not dotenv.load_dotenv():
warnings.warn('.env not loaded; application may break!', UserWarning) warnings.warn('.env not loaded; application may break!', RuntimeWarning)
class AppConfig(ConfigOptions):
secret_key = ConfigValue(required=True)
database_url = ConfigValue(required=True)
app_name = ConfigValue()
domain_name = ConfigValue()
private_assets = ConfigValue(cast=ssv_list)
jquery_url = ConfigValue(default='https://ajax.googleapis.com/ajax/libs/jquery/3.7.1/jquery.min.js')
app_config = AppConfig()
app = Flask(__name__) app = Flask(__name__)
app.secret_key = os.getenv('SECRET_KEY') app.secret_key = app_config.secret_key
app.config['SQLALCHEMY_DATABASE_URI'] = os.getenv('DATABASE_URL') app.config['SQLALCHEMY_DATABASE_URI'] = app_config.database_url
app.config['JSONIFY_PRETTYPRINT_REGULAR'] = False app.config['JSONIFY_PRETTYPRINT_REGULAR'] = False
from .models import db, User, Post from .models import db, User, Post
from .iding import id_from_b32l, id_to_b32l
# SASS # SASS
app.wsgi_app = SassMiddleware(app.wsgi_app, dict( app.wsgi_app = SassMiddleware(app.wsgi_app, dict(
@ -40,9 +57,9 @@ class SlugConverter(BaseConverter):
class B32lConverter(BaseConverter): class B32lConverter(BaseConverter):
regex = r'_?[a-z2-7]+' regex = r'_?[a-z2-7]+'
def to_url(self, value): def to_url(self, value):
return id_to_b32l(value) return Snowflake(value).to_b32l()
def to_python(self, value): 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['slug'] = SlugConverter
app.url_map.converters['b32l'] = B32lConverter app.url_map.converters['b32l'] = B32lConverter
@ -62,33 +79,40 @@ PRIVATE_ASSETS = os.getenv('PRIVATE_ASSETS', '').split()
@app.context_processor @app.context_processor
def _inject_variables(): def _inject_variables():
return { return {
'app_name': os.getenv('APP_NAME'), 'app_name': app_config.app_name,
'app_version': __version__, 'app_version': __version__,
'domain_name': os.getenv('DOMAIN_NAME'), 'domain_name': app_config.domain_name,
'url_for_css': (lambda name, **kwargs: url_for('static', filename=f'css/{name}.css', **kwargs)), 'url_for_css': (lambda name, **kwargs: url_for('static', filename=f'css/{name}.css', **kwargs)),
'private_scripts': [x for x in PRIVATE_ASSETS if x.endswith('.js')], 'private_scripts': [x for x in app_config.private_assets if x.endswith('.js')],
'private_styles': [x for x in PRIVATE_ASSETS if x.endswith('.css')], 'private_styles': [x for x in PRIVATE_ASSETS if x.endswith('.css')],
'jquery_url': os.getenv('JQUERY_URL') or 'https://ajax.googleapis.com/ajax/libs/jquery/3.7.1/jquery.min.js', 'jquery_url': app_config.jquery_url,
'post_count': Post.count(), 'post_count': Post.count(),
'user_count': User.active_count() 'user_count': User.active_count(),
'colors': color_themes,
'theme_classes': theme_classes
} }
@login_manager.user_loader @login_manager.user_loader
def _inject_user(userid): def _inject_user(userid):
try: try:
return db.session.execute(select(User).where(User.id == userid)).scalar() u = db.session.execute(select(User).where(User.id == userid)).scalar()
except Exception: if u is None or u.is_disabled:
warnings.warn(f'cannot retrieve user {userid} from db', RuntimeWarning) return None
return u
except SQLAlchemyError as e:
warnings.warn(f'cannot retrieve user {userid} from db (exception: {e})', RuntimeWarning)
g.no_user = True g.no_user = True
return None return None
def redact_url_password(u: str | Any) -> str | Any:
if not isinstance(u, str):
return u
return re.sub(r':[^@:/ ]+@', ':***@', u)
@app.errorhandler(ProgrammingError) @app.errorhandler(ProgrammingError)
def error_db(body): def error_db(body):
g.no_user = True g.no_user = True
warnings.warn(f'No database access! (url is {app.config['SQLALCHEMY_DATABASE_URI']})', RuntimeWarning) warnings.warn(f'No database access! (url is {redact_url_password(app.config['SQLALCHEMY_DATABASE_URI'])!r})', 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 return render_template('500.html'), 500
@app.errorhandler(400) @app.errorhandler(400)

View file

@ -7,7 +7,8 @@ AJAX hooks for the website.
import re import re
from flask import Blueprint, request from flask import Blueprint, request
from .models import Topic, db, User, Post, PostUpvote from sqlalchemy import delete, insert, select
from .models import Guild, db, User, Post, PostUpvote
from flask_login import current_user, login_required from flask_login import current_user, login_required
bp = Blueprint('ajax', __name__) bp = Blueprint('ajax', __name__)
@ -18,7 +19,7 @@ def username_availability(username: str):
is_valid = re.fullmatch('[a-z0-9_-]+', username) is not None is_valid = re.fullmatch('[a-z0-9_-]+', username) is not None
if is_valid: if is_valid:
user = db.session.execute(db.select(User).where(User.username == username)).scalar() user = db.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
else: else:
@ -32,10 +33,10 @@ def username_availability(username: str):
@bp.route('/guild_name_availability/<username>') @bp.route('/guild_name_availability/<username>')
def guild_name_availability(name: str): def guild_name_availability(name: str):
is_valid = re.fullmatch('[a-z0-9_-]+', username) is not None is_valid = re.fullmatch('[a-z0-9_-]+', name) is not None
if is_valid: if is_valid:
gd = db.session.execute(db.select(Topic).where(Topic.name == name)).scalar() gd = db.session.execute(select(Guild).where(Guild.name == name)).scalar()
is_available = gd is None is_available = gd is None
else: else:
@ -51,19 +52,19 @@ def guild_name_availability(name: str):
@login_required @login_required
def post_upvote(id): def post_upvote(id):
o = request.form['o'] o = request.form['o']
p: Post | None = db.session.execute(db.select(Post).where(Post.id == id)).scalar() p: Post | None = db.session.execute(select(Post).where(Post.id == id)).scalar()
if p is None: if p is None:
return { 'status': 'fail', 'message': 'Post not found' }, 404 return { 'status': 'fail', 'message': 'Post not found' }, 404
if o == '1': 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(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)) db.session.execute(insert(PostUpvote).values(post_id = p.id, voter_id = current_user.id, is_downvote = False))
elif o == '0': elif o == '0':
db.session.execute(db.delete(PostUpvote).where(PostUpvote.c.post_id == p.id, PostUpvote.c.voter_id == current_user.id)) db.session.execute(delete(PostUpvote).where(PostUpvote.c.post_id == p.id, PostUpvote.c.voter_id == current_user.id))
elif o == '-1': 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(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)) db.session.execute(insert(PostUpvote).values(post_id = p.id, voter_id = current_user.id, is_downvote = True))
else: else:
return { 'status': 'fail', 'message': 'Invalid score' }, 400 return { 'status': 'fail', 'message': 'Invalid score' }, 400

View file

@ -2,30 +2,33 @@
from flask_login import current_user from flask_login import current_user
from sqlalchemy import func, select from sqlalchemy import func, select
from .models import db, Post, Topic, User from .models import db, Post, Guild, User
def cuser() -> User: def cuser() -> User:
return current_user if current_user.is_authenticated else None return current_user if current_user.is_authenticated else None
def cuser_id() -> int:
return current_user.id if current_user.is_authenticated else None
def public_timeline(): def public_timeline():
return select(Post).join(User, User.id == Post.author_id).where( return select(Post).join(User, User.id == Post.author_id).where(
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()) ).order_by(Post.created_at.desc())
def topic_timeline(topic_name): def topic_timeline(gname):
return select(Post).join(Topic).join(User, User.id == Post.author_id).where( return select(Post).join(Guild).join(User, User.id == Post.author_id).where(
Post.privacy == 0, Topic.name == topic_name, User.not_suspended(), Post.not_removed() Post.privacy == 0, Guild.name == gname, User.not_suspended(), Post.not_removed(), User.has_not_blocked(Post.author_id, cuser_id())
).order_by(Post.created_at.desc()) ).order_by(Post.created_at.desc())
def user_timeline(user_id): def user_timeline(user_id):
return select(Post).join(User, User.id == Post.author_id).where( return select(Post).join(User, User.id == Post.author_id).where(
Post.visible_by(cuser()), User.id == user_id, User.not_suspended(), Post.not_removed() Post.visible_by(cuser()), User.id == user_id, User.not_suspended(), Post.not_removed(), User.has_not_blocked(Post.author_id, cuser_id())
).order_by(Post.created_at.desc()) ).order_by(Post.created_at.desc())
def top_guilds_query(): def top_guilds_query():
q_post_count = func.count().label('post_count') q_post_count = func.count().label('post_count')
qr = select(Topic, q_post_count)\ qr = select(Guild, q_post_count)\
.join(Post, Post.topic_id == Topic.id).group_by(Topic)\ .join(Post, Post.topic_id == Guild.id).group_by(Guild)\
.having(q_post_count > 5).order_by(q_post_count.desc()) .having(q_post_count > 5).order_by(q_post_count.desc())
return qr return qr

View file

@ -21,8 +21,12 @@ def main():
engine = create_engine(os.getenv('DATABASE_URL')) engine = create_engine(os.getenv('DATABASE_URL'))
if args.upgrade: if args.upgrade:
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
db.metadata.create_all(engine) db.metadata.create_all(engine)
subprocess.Popen(['alembic', 'upgrade', 'head']).wait()
print('Schema upgraded!') print('Schema upgraded!')
if args.flush: if args.flush:

39
freak/colors.py Normal file
View file

@ -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, 'Leek'),
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)

77
freak/dei.py Normal file
View file

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

View file

@ -1,78 +1,36 @@
import re, markdown import markdown
from markdown.inlinepatterns import InlineProcessor, SimpleTagInlineProcessor
import xml.etree.ElementTree as etree
from markupsafe import Markup from markupsafe import Markup
from suou import Snowflake
from suou.markdown import StrikethroughExtension, SpoilerExtension, PingExtension
from . import app 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 '<del>' + match.group(1) + '</del>'
### 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() 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() @app.template_filter()
def to_markdown(text, toc = False): def to_markdown(text, toc = False):
extensions = [ extensions = [
'tables', 'footnotes', 'fenced_code', 'sane_lists', 'tables', 'footnotes', 'fenced_code', 'sane_lists',
StrikethroughExtension(), SpoilerExtension(), StrikethroughExtension(), SpoilerExtension(),
## XXX untested PingExtension({'@': '/@', '+': '/+'})
PingExtension()
] ]
if toc: if toc:
extensions.append('toc') extensions.append('toc')
return Markup(markdown.Markdown(extensions=extensions).convert(text)) return Markup(markdown.Markdown(extensions=extensions).convert(text))
app.template_filter('markdown')(to_markdown)
@app.template_filter() @app.template_filter()
def to_b32l(n): def to_b32l(n):
return id_to_b32l(n) return Snowflake(n).to_b32l()
app.template_filter('b32l')(to_b32l)
@app.template_filter() @app.template_filter()
def append(text, l): def append(text, l: list):
l.append(text) l.append(text)
return None return None

View file

@ -1,17 +1,24 @@
""" """
DEPRECATED use suou.snowflake instead.
PSA: this module is for the LEGACY (v2) iding. PSA: this module is for the LEGACY (v2) iding.
For the SIQ-based ID's (upcoming 0.4), see suou.iding <https://github.com/sakuragasaki46/suou> For the SIQ-based ID's, see suou.iding <https://github.com/sakuragasaki46/suou>.
The suou library also provides snowflake support.
""" """
import base64 import base64
import os import os
import time import time
from suou.functools import deprecated
epoch = 1577833200000 epoch = 1577833200000
machine_id = int(os.getenv("MACHINE_ID", "0")) machine_id = int(os.getenv("MACHINE_ID", "0"))
machine_counter = 0 machine_counter = 0
@deprecated('use SnowflakeGen(). Planned for removal in 0.5')
def new_id(*, from_date = None): def new_id(*, from_date = None):
global machine_counter global machine_counter
@ -28,14 +35,16 @@ def new_id(*, from_date = None):
((machine_counter := machine_counter + 1) % 1024) ((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 ( return (
'_' if n < 0 else '' '_' if n < 0 else ''
) + base64.b32encode( ) + base64.b32encode(
(-n if n < 0 else n).to_bytes(10, 'big') (-n if n < 0 else n).to_bytes(10, 'big')
).decode().lstrip('A').lower() ).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( return (-1 if s.startswith('_') else 1) * int.from_bytes(
base64.b32decode(s.lstrip('_').upper().rjust(16, 'A').encode()), 'big' base64.b32decode(s.lstrip('_').upper().rjust(16, 'A').encode()), 'big'
) )

View file

@ -4,18 +4,20 @@ from __future__ import annotations
from collections import namedtuple from collections import namedtuple
import datetime import datetime
from functools import lru_cache from functools import partial
from operator import or_ from operator import or_
from threading import Lock from threading import Lock
from sqlalchemy import Column, String, ForeignKey, and_, text, \ from sqlalchemy import Column, Integer, String, ForeignKey, UniqueConstraint, text, \
CheckConstraint, Date, DateTime, Boolean, func, BigInteger, \ CheckConstraint, Date, DateTime, Boolean, func, BigInteger, \
SmallInteger, select, insert, update, create_engine, Table SmallInteger, select, update, Table
from sqlalchemy.orm import Relationship, declarative_base, relationship from sqlalchemy.orm import Relationship, relationship
from flask_sqlalchemy import SQLAlchemy from flask_sqlalchemy import SQLAlchemy
from flask_login import AnonymousUserMixin from flask_login import AnonymousUserMixin
from suou import SiqType, Snowflake, Wanted, deprecated, not_implemented
from suou.sqlalchemy import create_session, declarative_base, id_column, parent_children, snowflake_column
from werkzeug.security import check_password_hash from werkzeug.security import check_password_hash
import os
from .iding import new_id, id_to_b32l from freak import app_config
from .utils import age_and_days, get_remote_addr, timed_cache from .utils import age_and_days, get_remote_addr, timed_cache
@ -25,23 +27,27 @@ USER_ACTIVE = 0
USER_INACTIVE = 1 USER_INACTIVE = 1
USER_BANNED = 2 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 = [ post_report_reasons = [
## emergency
ReportReason(110, 'hate_speech', 'Extreme hate speech / terrorism'), 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(142, 'revenge_sxm', 'Revenge porn'),
ReportReason(122, 'black_market', 'Sale or promotion of regulated goods (e.g. firearms)'), ReportReason(122, 'black_market', 'Sale or promotion of regulated goods (e.g. firearms)'),
## urgent
ReportReason(171, 'xxx', 'Pornography'), ReportReason(171, 'xxx', 'Pornography'),
ReportReason(111, 'tasteless', 'Extreme violence / gore'), ReportReason(111, 'tasteless', 'Extreme violence / gore'),
ReportReason(180, 'impersonation', 'Impersonation'), ReportReason(180, 'impersonation', 'Impersonation'),
ReportReason(141, 'doxing', 'Diffusion of PII (personally identifiable information)'), 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'), ## less urgent
ReportReason(140, 'bullying', 'Harassment, bullying, or suicide incitement'), ReportReason(140, 'bullying', 'Harassment, bullying, or suicide incitement'),
ReportReason(112, 'lgbt_hate', 'Hate speech against LGBTQ+ or women'), ReportReason(112, 'lgbt_hate', 'Hate speech against LGBTQ+ or women'),
ReportReason(150, 'security_exploit', 'Dangerous security exploit or violation'), ReportReason(150, 'security_exploit', 'Dangerous security exploit or violation'),
ReportReason(190, 'false_information', 'False or deceiving information'), 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_REASON_STRINGS = { **{x.num_code: x.description for x in post_report_reasons}, **{x.code: x.description for x in post_report_reasons} }
@ -58,20 +64,18 @@ REPORT_UPDATE_ON_HOLD = 3
## END constants and enums ## END constants and enums
Base = declarative_base() Base = declarative_base(app_config.domain_name, app_config.secret_key,
snowflake_epoch=1577833200)
db = SQLAlchemy(model_class=Base) db = SQLAlchemy(model_class=Base)
def create_session_interactively(): CSI = create_session_interactively = partial(create_session, app_config.database_url)
'''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
## TODO replace with suou.declarative_base() - upcoming 0.4 # the BaseModel() class will be removed in 0.5
from .iding import new_id
@deprecated('id_column() and explicit id column are better. Will be removed in 0.5')
class BaseModel(Base): class BaseModel(Base):
__abstract__ = True __abstract__ = True
id = Column(BigInteger, primary_key=True, default=new_id) id = Column(BigInteger, primary_key=True, default=new_id)
## Many-to-many relationship keys for some reasons have to go ## Many-to-many relationship keys for some reasons have to go
@ -86,10 +90,22 @@ PostUpvote = Table(
Column('is_downvote', Boolean, server_default=text('false')) Column('is_downvote', Boolean, server_default=text('false'))
) )
class User(BaseModel): UserBlock = Table(
__tablename__ = 'freak_user' '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) 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) display_name = Column(String(64), nullable=False)
@ -102,7 +118,10 @@ class User(BaseModel):
is_disabled_by_user = Column(Boolean, server_default=text('false'), nullable=False) is_disabled_by_user = Column(Boolean, server_default=text('false'), nullable=False)
karma = Column(BigInteger, server_default=text('0'), nullable=False) karma = Column(BigInteger, server_default=text('0'), nullable=False)
legacy_id = Column(BigInteger, nullable=True) legacy_id = Column(BigInteger, nullable=True)
# TODO add pronouns and biography (upcoming 0.4)
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 # moderation
banned_at = Column(DateTime, nullable=True) banned_at = Column(DateTime, nullable=True)
@ -111,17 +130,21 @@ class User(BaseModel):
banned_until = Column(DateTime, nullable=True) banned_until = Column(DateTime, nullable=True)
banned_message = Column(String(256), nullable=True) banned_message = Column(String(256), nullable=True)
# 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 # 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 ## XXX posts and comments relationships are temporarily disabled because they make
## SQLAlchemy fail initialization of models — bricking the app. ## SQLAlchemy fail initialization of models — bricking the app.
## Posts are queried manually anyway ## Posts are queried manually anyway
#posts = relationship("Post", back_populates='author', )
upvoted_posts = relationship("Post", secondary=PostUpvote, back_populates='upvoters')
#comments = relationship("Comment", back_populates='author')
@property @property
def is_disabled(self): def is_disabled(self):
return self.banned_at is not None or self.is_disabled_by_user return (self.banned_at is not None and (self.banned_until is None or self.banned_until <= datetime.datetime.now())) or self.is_disabled_by_user
@property @property
def is_active(self): def is_active(self):
@ -151,7 +174,7 @@ class User(BaseModel):
""" """
## XXX change func name? ## XXX change func name?
return dict( return dict(
id = id_to_b32l(self.id), id = Snowflake(self.id).to_b32l(),
username = self.username, username = self.username,
display_name = self.display_name, display_name = self.display_name,
age = self.age() age = self.age()
@ -159,15 +182,18 @@ class User(BaseModel):
) )
def reward(self, points=1): def reward(self, points=1):
"""
Manipulate a user's karma on the fly
"""
with Lock(): with Lock():
db.session.execute(update(User).where(User.id == self.id).values(karma = self.karma + points)) db.session.execute(update(User).where(User.id == self.id).values(karma = self.karma + points))
db.session.commit() db.session.commit()
def can_create_guild(self): def can_create_guild(self):
## TODO make guild creation requirements configurable
return self.karma > 15 or self.is_administrator return self.karma > 15 or self.is_administrator
## deprecated alias! can_create_community = deprecated('use .can_create_guild()')(can_create_guild)
can_create_community = can_create_guild
def handle(self): def handle(self):
return f'@{self.username}' return f'@{self.username}'
@ -188,6 +214,22 @@ class User(BaseModel):
def not_suspended(cls): def not_suspended(cls):
return or_(User.banned_at == None, User.banned_until <= datetime.datetime.now()) return or_(User.banned_at == None, User.banned_until <= datetime.datetime.now())
@classmethod
def has_not_blocked(cls, actor, target):
"""
Filter out a content if the author has blocked current user.
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()
print(qq)
return qq
def recompute_karma(self): def recompute_karma(self):
c = 0 c = 0
c += db.session.execute(select(func.count('*')).select_from(Post).where(Post.author == self)).scalar() c += db.session.execute(select(func.count('*')).select_from(Post).where(Post.author == self)).scalar()
@ -196,10 +238,19 @@ class User(BaseModel):
self.karma = c self.karma = c
class Topic(BaseModel): @timed_cache(60)
__tablename__ = 'freak_topic' def strike_count(self) -> int:
return db.session.execute(select(func.count('*')).select_from(UserStrike).where(UserStrike.user_id == self.id)).scalar()
id = Column(BigInteger, primary_key=True, default=new_id, unique=True) ## END User
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) 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) display_name = Column(String(64), nullable=False)
@ -207,8 +258,12 @@ class Topic(BaseModel):
created_at = Column(DateTime, server_default=func.current_timestamp(), index=True, nullable=False) 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) owner_id = Column(BigInteger, ForeignKey('freak_user.id', name='topic_owner_id'), nullable=True)
language = Column(String(16), server_default=text("'en-US'")) 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) legacy_id = Column(BigInteger, nullable=True)
def url(self): def url(self):
@ -218,16 +273,56 @@ class Topic(BaseModel):
return f'+{self.name}' return f'+{self.name}'
# utilities # utilities
posts = relationship('Post', back_populates='topic') posts = relationship('Post', back_populates='guild')
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)
guild = relationship(Guild)
banned_by = relationship(User, primaryjoin= lambda: User.id == Member.banned_by_id)
@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_DEFAULT = 0
POST_TYPE_LINK = 1 POST_TYPE_LINK = 1
class Post(BaseModel): class Post(Base):
__tablename__ = 'freak_post' __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) 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) title = Column(String(256), nullable=False)
@ -251,16 +346,17 @@ class Post(BaseModel):
# utilities # utilities
author: Relationship[User] = relationship("User", lazy='selectin', foreign_keys=[author_id])#, back_populates="posts") author: Relationship[User] = relationship("User", lazy='selectin', foreign_keys=[author_id])#, back_populates="posts")
topic = relationship("Topic", back_populates="posts", lazy='selectin') guild = relationship("Guild", back_populates="posts", lazy='selectin')
comments = relationship("Comment", back_populates="parent_post") comments = relationship("Comment", back_populates="parent_post")
upvoters = relationship("User", secondary=PostUpvote, back_populates='upvoted_posts') upvoters = relationship("User", secondary=PostUpvote, back_populates='upvoted_posts')
def topic_or_user(self) -> Topic | User: def topic_or_user(self) -> Guild | User:
return self.topic or self.author return self.guild or self.author
def url(self): 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 '')
@not_implemented
def generate_slug(self): def generate_slug(self):
return slugify.slugify(self.title, max_length=64) return slugify.slugify(self.title, max_length=64)
@ -271,7 +367,7 @@ class Post(BaseModel):
def upvoted_by(self, user: User | AnonymousUserMixin | None): def upvoted_by(self, user: User | AnonymousUserMixin | None):
if not user or not user.is_authenticated: if not user or not user.is_authenticated:
return 0 return 0
v = db.session.execute(db.select(PostUpvote.c).where(PostUpvote.c.voter_id == user.id, PostUpvote.c.post_id == self.id)).fetchone() v: PostUpvote | None = db.session.execute(select(PostUpvote.c).where(PostUpvote.c.voter_id == user.id, PostUpvote.c.post_id == self.id)).fetchone()
if v: if v:
if v.is_downvote: if v.is_downvote:
return -1 return -1
@ -282,7 +378,7 @@ class Post(BaseModel):
return db.session.execute(select(Comment).where(Comment.parent_comment == None, Comment.parent_post == self).order_by(Comment.created_at.desc()).limit(limit)).scalars() return db.session.execute(select(Comment).where(Comment.parent_comment == None, Comment.parent_post == self).order_by(Comment.created_at.desc()).limit(limit)).scalars()
def report_url(self) -> str: def report_url(self) -> str:
return '/report/post/' + id_to_b32l(self.id) return f'/report/post/{Snowflake(self.id):l}'
def report_count(self) -> int: 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() return db.session.execute(select(func.count('*')).select_from(PostReport).where(PostReport.target_id == self.id, ~PostReport.update_status.in_((1, 2)))).scalar()
@ -305,12 +401,13 @@ class Post(BaseModel):
return or_(Post.author_id == user.id, Post.privacy.in_((0, 1))) return or_(Post.author_id == user.id, Post.privacy.in_((0, 1)))
class Comment(BaseModel): class Comment(Base):
__tablename__ = 'freak_comment' __tablename__ = 'freak_comment'
__table_args__ = (
UniqueConstraint('id', name='comment_id_uniq'),
)
# tweak to allow remote_side to work id = snowflake_column()
## XXX will be changed in 0.4 to suou.id_column()
id = Column(BigInteger, primary_key=True, default=new_id, unique=True)
author_id = Column(BigInteger, ForeignKey('freak_user.id', name='comment_author_id'), nullable=True) 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'), nullable=False)
@ -321,6 +418,7 @@ class Comment(BaseModel):
updated_at = Column(DateTime, nullable=True) updated_at = Column(DateTime, nullable=True)
is_locked = Column(Boolean, server_default=text('false')) is_locked = Column(Boolean, server_default=text('false'))
## DO NOT FILL IN! intended for 0.2 or earlier
legacy_id = Column(BigInteger, nullable=True) legacy_id = Column(BigInteger, nullable=True)
removed_at = Column(DateTime, nullable=True) removed_at = Column(DateTime, nullable=True)
@ -328,15 +426,14 @@ class Comment(BaseModel):
removed_reason = Column(SmallInteger, nullable=True) removed_reason = Column(SmallInteger, nullable=True)
author = relationship('User', foreign_keys=[author_id])#, back_populates='comments') author = relationship('User', foreign_keys=[author_id])#, back_populates='comments')
parent_post = relationship("Post", back_populates="comments", foreign_keys=[parent_post_id]) parent_post: Relationship[Post] = relationship("Post", back_populates="comments", foreign_keys=[parent_post_id])
parent_comment = relationship("Comment", back_populates="child_comments", remote_side=[id]) parent_comment, child_comments = parent_children('comment', parent_remote_side=Wanted('id'))
child_comments = relationship("Comment", back_populates="parent_comment")
def url(self): 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}'
def report_url(self) -> str: 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: 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() return db.session.execute(select(func.count('*')).select_from(PostReport).where(PostReport.target_id == self.id, ~PostReport.update_status.in_((1, 2)))).scalar()
@ -349,9 +446,11 @@ class Comment(BaseModel):
def not_removed(cls): def not_removed(cls):
return Post.removed_at == None return Post.removed_at == None
class PostReport(BaseModel): class PostReport(Base):
__tablename__ = 'freak_postreport' __tablename__ = 'freak_postreport'
id = snowflake_column()
author_id = Column(BigInteger, ForeignKey('freak_user.id', name='report_author_id'), nullable=True) author_id = Column(BigInteger, ForeignKey('freak_user.id', name='report_author_id'), nullable=True)
target_type = Column(SmallInteger, nullable=False) target_type = Column(SmallInteger, nullable=False)
target_id = Column(BigInteger, nullable=False) target_id = Column(BigInteger, nullable=False)
@ -370,6 +469,27 @@ class PostReport(BaseModel):
else: else:
return self.target_id 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)
issued_by = relationship(User, primaryjoin= lambda: User.id == UserStrike.issued_by_id)
# PostUpvote table is at the top !! # PostUpvote table is at the top !!

View file

@ -2,8 +2,8 @@
from flask import Blueprint from flask import Blueprint
from flask_restx import Resource, Api from flask_restx import Resource, Api
from sqlalchemy import select
from freak.iding import id_to_b32l from suou import Snowflake
from ..models import Post, User, db from ..models import Post, User, db
@ -21,31 +21,31 @@ class Nurupo(Resource):
@rest.route('/user/<b32l:id>') @rest.route('/user/<b32l:id>')
class UserInfo(Resource): class UserInfo(Resource):
def get(self, id: int): def get(self, id: int):
u: User | None = db.session.execute(db.select(User).where(User.id == id)).scalar() u: User | None = db.session.execute(select(User).where(User.id == id)).scalar()
if u is None: if u is None:
return dict(error='User not found'), 404 return dict(error='User not found'), 404
uj = dict( uj = dict(
id = id_to_b32l(u.id), id = f'{Snowflake(u.id):l}',
username = u.username, username = u.username,
display_name = u.display_name, display_name = u.display_name,
joined_at = u.joined_at.isoformat('T'), joined_at = u.joined_at.isoformat('T'),
karma = u.karma, karma = u.karma,
age = u.age() age = u.age()
) )
return dict(users={id_to_b32l(id): uj}) return dict(users={f'{Snowflake(id):l}': uj})
@rest.route('/post/<b32l:id>') @rest.route('/post/<b32l:id>')
class SinglePost(Resource): class SinglePost(Resource):
def get(self, id: int): def get(self, id: int):
p: Post | None = db.session.execute(db.select(Post).where(Post.id == id)).scalar() p: Post | None = db.session.execute(select(Post).where(Post.id == id)).scalar()
if p is None: if p is None:
return dict(error='Not found'), 404 return dict(error='Not found'), 404
pj = dict( pj = dict(
id = id_to_b32l(p.id), id = f'{Snowflake(p.id):l}',
title = p.title, title = p.title,
author = p.author.simple_info(), author = p.author.simple_info(),
to = p.topic_or_user().handle(), to = p.topic_or_user().handle(),
created_at = p.created_at.isoformat('T') created_at = p.created_at.isoformat('T')
) )
return dict(posts={id_to_b32l(id): pj}) return dict(posts={f'{Snowflake(id):l}': pj})

View file

@ -1,14 +1,6 @@
(function(){ (function(){
// UNUSED! Period is disallowed regardless now "use strict";
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'
);
}
function attachUsernameInput(){ function attachUsernameInput(){
@ -140,9 +132,36 @@
}).then(e => e.json()); }).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() { function main() {
attachUsernameInput(); attachUsernameInput();
enablePostVotes(); enablePostVotes();
enableThemeChange();
} }
main(); main();

View file

@ -5,7 +5,22 @@
box-sizing: border-box box-sizing: border-box
\:root \: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: #a828ba
--c11-accent: #1871d8
--c12-accent: #885a18
--c13-accent: #38a856
--c14-accent: #ff3018
--c15-accent: #ff1668
--light-text-primary: #181818 --light-text-primary: #181818
--light-text-alt: #444 --light-text-alt: #444
@ -25,6 +40,8 @@
--dark-background: #181a21 --dark-background: #181a21
--dark-bg-sharp: #080808 --dark-bg-sharp: #080808
--accent: var(--c0-accent)
// the following are DEPRECATED // // the following are DEPRECATED //
--light-accent: var(--accent) --light-accent: var(--accent)
--dark-accent: var(--accent) --dark-accent: var(--accent)
@ -49,7 +66,7 @@
--background: var(--dark-background) --background: var(--dark-background)
--bg-sharp: var(--dark-bg-sharp) --bg-sharp: var(--dark-bg-sharp)
body.color-scheme-light .color-scheme-light
--text-primary: var(--light-text-primary) --text-primary: var(--light-text-primary)
--text-alt: var(--light-text-alt) --text-alt: var(--light-text-alt)
--border: var(--light-border) --border: var(--light-border)
@ -59,7 +76,7 @@ body.color-scheme-light
--background: var(--light-background) --background: var(--light-background)
--bg-sharp: var(--light-bg-sharp) --bg-sharp: var(--light-bg-sharp)
body.color-scheme-dark .color-scheme-dark
--text-primary: var(--dark-text-primary) --text-primary: var(--dark-text-primary)
--text-alt: var(--dark-text-alt) --text-alt: var(--dark-text-alt)
--border: var(--dark-border) --border: var(--dark-border)
@ -69,6 +86,51 @@ body.color-scheme-dark
--background: var(--dark-background) --background: var(--dark-background)
--bg-sharp: var(--dark-bg-sharp) --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 body, input, select, button
font-family: $ui-fonts font-family: $ui-fonts

View file

@ -54,6 +54,11 @@ header.header
&, > ul, > ul > li:has(.mini-search-bar) &, > ul, > ul > li:has(.mini-search-bar)
flex: 1 flex: 1
ul > li span
color: var(--text-primary)
font-size: .6em
.header-username .header-username
> * > *
display: block display: block
@ -135,6 +140,7 @@ ul.inline
list-style: none list-style: none
padding: 0 padding: 0
margin: 0 margin: 0
display: inline
> li > li
display: inline display: inline
&::before &::before
@ -144,6 +150,22 @@ ul.inline
content: '' content: ''
margin: 0 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 ul.message-options
color: var(--text-alt) color: var(--text-alt)
list-style: none list-style: none
@ -280,7 +302,7 @@ button, [type="submit"], [type="reset"], [type="button"]
&.primary &.primary
background-color: var(--accent) background-color: var(--accent)
color: var(--bg-main) color: var(--background)
&[disabled] &[disabled]
opacity: .5 opacity: .5
@ -306,5 +328,27 @@ button, [type="submit"], [type="reset"], [type="button"]
width: 0 width: 0
margin-right: auto margin-right: auto
.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)

View file

@ -6,11 +6,19 @@
.content-nav, .content-main .content-nav, .content-main
width: 100% width: 100%
ul.grid
grid-template-columns: 1fr 1fr
.nomobile
display: none
@media screen and (max-width: 960px) @media screen and (max-width: 960px)
.header-username .header-username
display: none display: none
header.header header.header
padding: .5em .5em
.mini-search-bar .mini-search-bar
display: none display: none
@ -19,3 +27,10 @@
ul > li:has(.mini-search-bar) ul > li:has(.mini-search-bar)
flex: unset flex: unset
// not mobile: //
@media screen and (min-width: 801px)
.mobileonly
display: none

View file

@ -6,11 +6,13 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" type="text/css" href="/static/css/style.css"> <link rel="stylesheet" type="text/css" href="/static/css/style.css">
<style>.done{opacity:.5}</style> {% for private_style in private_styles %}
<link rel="stylesheet" href="{{ private_style }}" />
{% endfor %}
</head> </head>
<body> <body class="admin">
<div class="header"> <div class="header">
<h1><a href="{{ url_for('admin.homepage') }}">{{ site_name }} Admin</a></h1> <h1><a href="{{ url_for('admin.homepage') }}"><span class="faint">{{ app_name }}:</span> Admin</a></h1>
</div> </div>
<div class="content"> <div class="content">
{% for message in get_flashed_messages() %} {% for message in get_flashed_messages() %}

View file

@ -1,9 +1,12 @@
{% extends "admin/admin_base.html" %} {% extends "admin/admin_base.html" %}
{% block content %} {% block content %}
<ul> <ul class="grid">
<li> <li>
<a href="{{ url_for('admin.reports') }}">Reports</a> <h2><a href="{{ url_for('admin.reports') }}">Reports</a></h2>
</li>
<li>
<h2><a href="{{ url_for('admin.strikes') }}">Strikes</a></h2>
</li> </li>
</ul> </ul>
{% endblock %} {% endblock %}

View file

@ -1,5 +1,6 @@
{% extends "admin/admin_base.html" %} {% extends "admin/admin_base.html" %}
{% from "macros/embed.html" import embed_post with context %} {% from "macros/embed.html" import embed_post with context %}
{% from "macros/icon.html" import icon, callout with context %}
{% block content %} {% block content %}
<h2>Report detail #{{ report.id }}</h2> <h2>Report detail #{{ report.id }}</h2>
@ -14,10 +15,20 @@
{% else %} {% else %}
<p><i>Unknown media type</i></p> <p><i>Unknown media type</i></p>
{% endif %} {% endif %}
{% if report.is_critical() %}
{% call callout('nsfw_language') %}
This is a critical offense. “Strike” will immediately suspend the offender's account.
{% endcall %}
{% endif %}
<form method="POST"> <form method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" /> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
<button type="submit" name="do" value="0">Reject</button> <button type="submit" name="do" value="0">Reject</button>
{% if report.is_critical() %}
<button type="submit" name="do" value="2" class="primary">Strike</button>
{% else %}
<button type="submit" name="do" value="1" class="primary">Remove</button> <button type="submit" name="do" value="1" class="primary">Remove</button>
<button type="submit" name="do" value="2">Strike</button>
{% endif %}
<button type="submit" name="do" value="2">Put on hold</button> <button type="submit" name="do" value="2">Put on hold</button>
</form> </form>
{% endblock %} {% endblock %}

View file

@ -0,0 +1,21 @@
{% extends "admin/admin_base.html" %}
{% from "macros/feed.html" import stop_scrolling, no_more_scrolling with context %}
{% block content %}
<ul>
{% for strike in strike_list %}
<li>
<p><strong>#{{ strike.id }}</strong> to {{ strike.user.handle() }}</p>
<ul class="inline">
<li>Reason: <strong>{{ report_reasons[strike.reason_code] }}</strong></li>
<!-- you might not want to see why -->
</ul>
</li>
{% endfor %}
{% if strike_list.has_next %}
{{ stop_scrolling(strike_list.page) }}
{% else %}
{{ no_more_scrolling(strike_list.page) }}
{% endif %}
</ul>
{% endblock %}

View file

@ -4,6 +4,7 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
{% from "macros/icon.html" import icon with context %}
{% block title %} {% block title %}
<title>{{ app_name }}</title> <title>{{ app_name }}</title>
{% endblock %} {% endblock %}
@ -25,7 +26,7 @@
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
<script src="{{ jquery_url }}"></script> <script src="{{ jquery_url }}"></script>
</head> </head>
<body> <body {% if current_user.color_theme %} class="{{ theme_classes(current_user.color_theme) }}"{% endif %}>
<header class="header"> <header class="header">
<h1><a href="/">{{ app_name }}</a></h1> <h1><a href="/">{{ app_name }}</a></h1>
<div class="metanav"> <div class="metanav">
@ -45,29 +46,27 @@
{% if g.no_user %} {% if g.no_user %}
<!-- no user --> <!-- no user -->
{% elif current_user.is_authenticated %} {% elif current_user.is_authenticated %}
<li><a href="/create" title="Create a post"> <li>
<a class="round border-accent" href="/create" title="Create a post">
<i class="icon icon-add"></i> <i class="icon icon-add"></i>
<span class="a11y">create</span> <span class="a11y">create</span>
</a></li><li><a href="{{ current_user.url() }}" <span class="nomobile">New post</span>
title="@{{ current_user.username }}'s profile"> </a>
<i class="icon icon-profile"></i>
<span class="a11y">profile</span>
</a></li><li>
<div class="header-username">
<strong class="header-username-name">@{{ current_user.username }}</strong>
<span class="header-username-karma"><i class="icon icon-karma"></i> {{ current_user.karma }} karma</span>
</div>
</li> </li>
<li><a href="{{ current_user.url() }}" title="{{ current_user.handle() }}'s profile">{{ icon('profile')}}<span class="a11y">profile</span></a>
<div class="header-username">
<strong class="header-username-name">{{ current_user.handle() }}</strong>
<span class="header-username-karma">{{ icon('karma') }} {{ current_user.karma }} karma</span>
</div></li>
<li><a href="/logout" title="Log out"> <li><a href="/logout" title="Log out">
<i class="icon icon-logout"></i><span class="a11y">log out</span> {{ icon('logout') }} <span class="a11y">log out</span>
</a></li> </a></li>
{% else %} {% else %}
<li><a href="/login" title="Log in"> <li><a href="/login" title="Log in">
<i class="icon icon-logout"></i> {{ icon('logout') }}<span class="a11y">log in</span>
<span class="a11y">log in</span> </a></li>
</a></li><li><a href="/register" title="Register"> <li><a href="/register" title="Register">
<i class="icon icon-join"></i> {{ icon('join') }}<span class="a11y">register</span>
<span class="a11y">register</span>
</a></li> </a></li>
{% endif %} {% endif %}
</ul> </ul>
@ -104,9 +103,9 @@
function changeAccentColorTime() { function changeAccentColorTime() {
let hours = (new Date).getHours(); let hours = (new Date).getHours();
if (hours < 6 || hours >= 19) { if (hours < 6 || hours >= 19) {
document.body.style.setProperty('--accent', '#1871d8'); document.body.classList.add('night');
} else { } else {
document.body.style.removeProperty('--accent'); document.body.classList.remove('night');
} }
} }
changeAccentColorTime(); changeAccentColorTime();

View file

@ -21,7 +21,7 @@
{% endif %} {% endif %}
{% if feed_type == 'guild' %} {% if feed_type == 'guild' %}
{{ nav_guild(topic) }} {{ nav_guild(guild) }}
{% endif %} {% endif %}
<aside class="card"> <aside class="card">

View file

View file

@ -4,8 +4,8 @@
<div class="message-meta">Posted by <a href="{{ p.author.url() }}">@{{ p.author.username }}</a> <div class="message-meta">Posted by <a href="{{ p.author.url() }}">@{{ p.author.username }}</a>
{% if p.parent_post %} {% if p.parent_post %}
as a comment on <a href="{{ p.parent_post.url() }}">post “{{ p.parent_post.title }}”</a> as a comment on <a href="{{ p.parent_post.url() }}">post “{{ p.parent_post.title }}”</a>
{% elif p.topic %} {% elif p.guild %}
on <a href="{{ p.topic.url() }}">+{{ p.topic.name }}</a> on <a href="{{ p.guild.url() }}">{{ p.guild.handle() }}</a>
{% else %} {% else %}
on their user page on their user page
{% endif %} {% endif %}

View file

@ -5,8 +5,8 @@
<div id="post-{{ p.id | to_b32l }}" class="post-frame" data-endpoint="{{ p.id | to_b32l }}"> <div id="post-{{ p.id | to_b32l }}" class="post-frame" data-endpoint="{{ p.id | to_b32l }}">
<h3 class="message-title"><a href="{{ p.url() }}">{{ p.title }}</a></h3> <h3 class="message-title"><a href="{{ p.url() }}">{{ p.title }}</a></h3>
<div class="message-meta">Posted by <a href="{{ p.author.url() }}">@{{ p.author.username }}</a> <div class="message-meta">Posted by <a href="{{ p.author.url() }}">@{{ p.author.username }}</a>
{% if p.topic %} {% if p.guild %}
on <a href="{{ p.topic.url() }}">+{{ p.topic.name }}</a> on <a href="{{ p.guild.url() }}">{{ p.guild.handle() }}</a>
{% else %} {% else %}
on their user page on their user page
{% endif %} {% endif %}

View file

@ -14,9 +14,11 @@
{% macro nav_user(user) %} {% macro nav_user(user) %}
<aside class="card"> <aside class="card">
<h3>About <a href="{{ user.url() }}">{{ user.handle() }}</a></h3> <h3>About <a href="{{ user.url() }}">{{ user.display_name or user.handle() }}</a></h3>
<ul> <ul>
{# <li> TODO user.biography </li> #} {% if user.biography %}
<li>{{ icon('info') }} {{ user.biography }}</li>
{% endif %}
</ul> </ul>
</aside> </aside>
{% endmacro %} {% endmacro %}

View file

@ -38,7 +38,7 @@
<label>{{ icon('calendar') }} Date of birth:</label> <label>{{ icon('calendar') }} Date of birth:</label>
<input type="date" name="birthday"><br> <input type="date" name="birthday"><br>
<small class="faint field_desc">Your birthday is not shown to anyone. Some age information may be made available for transparency.</small> <small class="faint field_desc">Your birthday is not shown to anyone. Some age information may be made available for transparency.</small>
<!-- You must be 14 years old or older to register on {{ app_name }}. --> <!-- You must be 14 years old or older to register on {{ app_name }}. You can try to evade the limits, but fuck around and find out -->
</div> </div>
{% if not current_user.is_anonymous %} {% if not current_user.is_anonymous %}
<div> <div>

View file

@ -18,7 +18,7 @@
button{border:1px solid var(--ac);border-radius:6px;color:var(--ac);background-color:transparent;opacity:.8;margin:6px 12px;padding:6px 12px;font:inherit} button{border:1px solid var(--ac);border-radius:6px;color:var(--ac);background-color:transparent;opacity:.8;margin:6px 12px;padding:6px 12px;font:inherit}
button.primary{background-color:var(--ac);color:var(--fg)} button.primary{background-color:var(--ac);color:var(--fg)}
button:hover{opacity:1;transition:2s ease;} button:hover{opacity:1;transition:2s ease;}
@media (prefers-color-scheme:dark){body{color:var(--fg);background-color:var(--bg)}} @media (prefers-color-scheme:dark){body{color:var(--fg);background-color:var(--bg)} }
footer{font-size:smaller;text-align:center;} footer{font-size:smaller;text-align:center;}
</style> </style>
</head> </head>

View file

@ -190,3 +190,5 @@ In case of conflicts or discrepancies between translations, the English version
The entire text of our General Regulation is free for everyone to use, as long as the text and its core concepts are not altered in a significant way. We encourage its adoption in order to make rules more clear, respecting them more mindless, and moderation easier. The entire text of our General Regulation is free for everyone to use, as long as the text and its core concepts are not altered in a significant way. We encourage its adoption in order to make rules more clear, respecting them more mindless, and moderation easier.
{% endfilter %} {% endfilter %}
</div>
{% endblock %}

View file

@ -15,8 +15,8 @@
{% endblock %} {% endblock %}
{% block nav %} {% block nav %}
{% if p.topic %} {% if p.guild %}
{{ nav_guild(p.topic) }} {{ nav_guild(p.guild) }}
{% elif p.author %} {% elif p.author %}
{{ nav_user(p.author) }} {{ nav_user(p.author) }}
{% endif %} {% endif %}
@ -29,8 +29,8 @@
<h1 class="message-title">{{ p.title }}</h1> <h1 class="message-title">{{ p.title }}</h1>
<div class="message-meta"> <div class="message-meta">
Posted by <a href="{{ p.author.url() }}">@{{ p.author.username }}</a> Posted by <a href="{{ p.author.url() }}">@{{ p.author.username }}</a>
{% if p.topic %} {% if p.guild %}
on <a href="{{ p.topic.url() }}">+{{ p.topic.name }}</a> on <a href="{{ p.guild.url() }}">+{{ p.guild.name }}</a>
{% else %} {% else %}
on their user page on their user page
{% endif %} {% endif %}

View file

@ -8,11 +8,18 @@
{% block heading %} {% block heading %}
<h2>{{ user.handle() }}</h2> <h2>{{ user.handle() }}</h2>
<p>{{ icon('karma') }} <strong>{{ user.karma }}</strong> karma - Joined at: <time datetime="{{ user.joined_at.isoformat('T') }}">{{ user.joined_at.strftime('%B %-d, %Y %H:%M') }}</time> - ID: {{ user.id|to_b32l }}</p> <ul class="inline">
<li>{{ icon('karma') }} <strong>{{ user.karma }}</strong> karma</li>
<li>Joined at: <time datetime="{{ user.joined_at.isoformat('T') }}">{{ user.joined_at.strftime('%B %-d, %Y %H:%M') }}</time></li>
<li>ID: {{ user.id|to_b32l }}</li>
</ul>
{% endblock %} {% endblock %}
{% block nav %} {% block nav %}
{{ nav_user(user) }} {{ nav_user(user) }}
{% if user == current_user %}
<a href="/settings"><button class="card">{{ icon('settings') }} Settings</button></a>
{% endif %}
{% endblock %} {% endblock %}
{% block content %} {% block content %}

View file

@ -0,0 +1,52 @@
{% extends "base.html" %}
{% from "macros/title.html" import title_tag with context %}
{% from "macros/create.html" import checked_if with context %}
{% block title %}{{ title_tag('User Settings') }}{% endblock %}
{% block heading %}
<h1>Settings for {{ current_user.handle() }}</h1>
{% endblock %}
{% block content %}
<form method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
<section class="card">
<h2>Identification</h2>
<div><label for="US__display_name">Full name:</label>
<input type="text" name="display_name" id="US__display_name" value="{{ current_user.display_name or '' }}" />
</div>
<div><label for="US__biography">Bio:</label>
<textarea name="biography" id="US__biography">{{ current_user.biography or '' }}</textarea>
</div>
<div>
<button type="submit" class="primary">Save</button>
</div>
</section>
<section class="card">
<h2>Appearance</h2>
<div>
<label>Color scheme</label>
<ul class="apply-theme">
<li><input type="radio" id="US__color_scheme_dark" name="color_scheme" value="dark" {{ checked_if((current_user.color_theme // 256) == 2) }}><label for="US__color_scheme_dark">Dark</label></li>
<li><input type="radio" id="US__color_scheme_light" name="color_scheme" value="light" {{ checked_if((current_user.color_theme // 256) == 1) }}><label for="US__color_scheme_light">Light</label></li>
<li><input type="radio" id="US__color_scheme_unset" name="color_scheme" value="unset" {{ checked_if((current_user.color_theme // 256) == 0) }}><label for="US__color_scheme_unset">System</label></li>
</ul>
</div>
<div>
<label>Color theme</label>
<ul class="apply-theme">
{% for color in colors %}
<li><input type="radio" id="US__color_theme_{{ color.code }}" name="color_theme" value="{{ color.code }}" {{ checked_if((current_user.color_theme % 256) == color.code) }}><label for="US__color_theme_{{ color.code }}">{{ color.name }}</label></li>
{% endfor %}
</ul>
<p><small class="faint">Don't forget to save your changes to apply the theme!</small></p>
</div><div>
<button type="submit" class="primary">Save</button>
</div>
</form>
</section>
</form>
{% endblock %}

View file

@ -2,8 +2,8 @@ import os, sys
import re import re
import datetime import datetime
from typing import Mapping from typing import Mapping
from flask import Blueprint, render_template, request, redirect, flash from flask import Blueprint, abort, render_template, request, redirect, flash
from flask_login import login_user, logout_user, current_user from flask_login import login_required, login_user, logout_user, current_user
from ..models import REPORT_REASONS, db, User from ..models import REPORT_REASONS, db, User
from ..utils import age_and_days from ..utils import age_and_days
from sqlalchemy import select, insert from sqlalchemy import select, insert
@ -101,3 +101,30 @@ def register():
return render_template('register.html') return render_template('register.html')
COLOR_SCHEMES = {'dark': 2, 'light': 1, 'system': 0, 'unset': 0}
@bp.route('/settings', methods=['GET', 'POST'])
@login_required
def settings():
if request.method == 'POST':
user: User = current_user
color_scheme = COLOR_SCHEMES[request.form.get('color_scheme')] if 'color_scheme' in request.form else None
color_theme = int(request.form.get('color_theme')) if 'color_theme' in request.form else None
biography = request.form.get('biography')
display_name = request.form.get('display_name')
changes = False
if display_name and display_name != user.display_name:
changes, user.display_name = True, display_name.strip()
if biography and biography != user.biography:
changes, user.biography = True, biography.strip()
if color_scheme is not None and color_theme is not None:
comp_color_theme = 256 * color_scheme + color_theme
if comp_color_theme != user.color_theme:
changes, user.color_theme = True, comp_color_theme
if changes:
db.session.add(user)
db.session.commit()
flash('Changes saved!')
return render_template('usersettings.html')

View file

@ -3,11 +3,13 @@
import datetime import datetime
from functools import wraps from functools import wraps
from typing import Callable from typing import Callable
import warnings
from flask import Blueprint, abort, redirect, render_template, request, url_for from flask import Blueprint, abort, redirect, render_template, request, url_for
from flask_login import current_user from flask_login import current_user
from sqlalchemy import select, update from sqlalchemy import insert, select, update
from suou import additem, not_implemented
from ..models import REPORT_REASON_STRINGS, REPORT_UPDATE_COMPLETE, REPORT_UPDATE_ON_HOLD, REPORT_UPDATE_REJECTED, Comment, Post, PostReport, User, db from ..models import REPORT_REASON_STRINGS, REPORT_TARGET_COMMENT, REPORT_TARGET_POST, REPORT_UPDATE_COMPLETE, REPORT_UPDATE_ON_HOLD, REPORT_UPDATE_REJECTED, Comment, Post, PostReport, User, UserStrike, db
bp = Blueprint('admin', __name__) bp = Blueprint('admin', __name__)
@ -22,36 +24,96 @@ def admin_required(func: Callable):
return func(**ka) return func(**ka)
return wrapper return wrapper
def accept_report(target, source: PostReport):
TARGET_TYPES = {
Post: REPORT_TARGET_POST,
Comment: REPORT_TARGET_COMMENT
}
def remove_content(target, reason_code: int):
if isinstance(target, Post): if isinstance(target, Post):
target.removed_at = datetime.datetime.now() target.removed_at = datetime.datetime.now()
target.removed_by_id = current_user.id target.removed_by_id = current_user.id
target.removed_reason = source.reason_code target.removed_reason = reason_code
elif isinstance(target, Comment): elif isinstance(target, Comment):
target.removed_at = datetime.datetime.now() target.removed_at = datetime.datetime.now()
target.removed_by_id = current_user.id target.removed_by_id = current_user.id
target.removed_reason = source.reason_code target.removed_reason = reason_code
db.session.add(target) db.session.add(target)
def get_author(target) -> User | None:
if isinstance(target, (Post, Comment)):
return target.author
return None
def get_content(target) -> str | None:
if isinstance(target, Post):
return target.title + '\n\n' + target.text_content
elif isinstance(target, Comment):
return target.text_content
return None
## REPORT ACTIONS ##
REPORT_ACTIONS = {}
@additem(REPORT_ACTIONS, '1')
def accept_report(target, source: PostReport):
if source.is_critical():
warnings.warn('attempted remove on a critical report case, striking instead', UserWarning)
return strike_report(target, source)
remove_content(target, source.reason_code)
source.update_status = REPORT_UPDATE_COMPLETE source.update_status = REPORT_UPDATE_COMPLETE
db.session.add(source) db.session.add(source)
db.session.commit() db.session.commit()
@additem(REPORT_ACTIONS, '2')
def strike_report(target, source: PostReport):
remove_content(target, source.reason_code)
author = get_author(target)
if author:
db.session.execute(insert(UserStrike).values(
user_id = author.id,
target_type = TARGET_TYPES[type(target)],
target_id = target.id,
target_content = get_content(target),
reason_code = source.reason_code,
issued_by_id = current_user.id
))
if source.is_critical():
author.banned_at = datetime.datetime.now()
author.banned_reason = source.reason_code
source.update_status = REPORT_UPDATE_COMPLETE
db.session.add(source)
db.session.commit()
@additem(REPORT_ACTIONS, '0')
def reject_report(target, source: PostReport): def reject_report(target, source: PostReport):
source.update_status = REPORT_UPDATE_REJECTED source.update_status = REPORT_UPDATE_REJECTED
db.session.add(source) db.session.add(source)
db.session.commit() db.session.commit()
@additem(REPORT_ACTIONS, '3')
def withhold_report(target, source: PostReport): def withhold_report(target, source: PostReport):
source.update_status = REPORT_UPDATE_ON_HOLD source.update_status = REPORT_UPDATE_ON_HOLD
db.session.add(source) db.session.add(source)
db.session.commit() db.session.commit()
REPORT_ACTIONS = {
'1': accept_report, @additem(REPORT_ACTIONS, '4')
'0': reject_report, @not_implemented()
'2': withhold_report def escalate_report(target, source: PostReport):
} ...
## END report actions
@bp.route('/admin/') @bp.route('/admin/')
@admin_required @admin_required
@ -77,3 +139,10 @@ def report_detail(id: int):
return redirect(url_for('admin.reports')) return redirect(url_for('admin.reports'))
return render_template('admin/admin_report_detail.html', report=report, return render_template('admin/admin_report_detail.html', report=report,
report_reasons=REPORT_REASON_STRINGS) report_reasons=REPORT_REASON_STRINGS)
@bp.route('/admin/strikes/')
@admin_required
def strikes():
strike_list = db.paginate(select(UserStrike).order_by(UserStrike.id.desc()))
return render_template('admin/admin_strikes.html',
strike_list=strike_list, report_reasons=REPORT_REASON_STRINGS)

View file

@ -4,8 +4,8 @@ import sys
import datetime import datetime
from flask import Blueprint, abort, redirect, flash, render_template, request, url_for from flask import Blueprint, abort, redirect, flash, render_template, request, url_for
from flask_login import current_user, login_required from flask_login import current_user, login_required
from sqlalchemy import insert from sqlalchemy import insert, select
from ..models import User, db, Topic, Post from ..models import User, db, Guild, Post
bp = Blueprint('create', __name__) bp = Blueprint('create', __name__)
@ -14,20 +14,20 @@ bp = Blueprint('create', __name__)
def create(): def create():
user: User = current_user user: User = current_user
if request.method == 'POST' and 'title' in request.form: if request.method == 'POST' and 'title' in request.form:
topic_name = request.form['to'] gname = request.form['to']
if topic_name: if gname:
topic: Topic | None = db.session.execute(db.select(Topic).where(Topic.name == topic_name)).scalar() guild: Guild | None = db.session.execute(select(Guild).where(Guild.name == gname)).scalar()
if topic is None: if guild is None:
flash(f'Topic +{topic_name} not found, posting to your user page instead') flash(f'Guild +{gname} not found or inaccessible, posting to your user page instead')
else: else:
topic = None guild = None
title = request.form['title'] title = request.form['title']
text = request.form['text'] text = request.form['text']
privacy = int(request.form.get('privacy', '0')) privacy = int(request.form.get('privacy', '0'))
try: try:
new_post: Post = db.session.execute(insert(Post).values( new_post: Post = db.session.execute(insert(Post).values(
author_id = user.id, author_id = user.id,
topic_id = topic.id if topic else None, topic_id = guild.id if guild else None,
created_at = datetime.datetime.now(), created_at = datetime.datetime.now(),
privacy = privacy, privacy = privacy,
title = title, title = title,
@ -35,7 +35,7 @@ def create():
).returning(Post.id)).fetchone() ).returning(Post.id)).fetchone()
db.session.commit() db.session.commit()
flash(f'Published on {'+' + topic_name if topic_name else '@' + user.username}') flash(f'Published on {guild.handle() if guild else user.handle()}')
return redirect(url_for('detail.post_detail', id=new_post.id)) return redirect(url_for('detail.post_detail', id=new_post.id))
except Exception as e: except Exception as e:
sys.excepthook(*sys.exc_info()) sys.excepthook(*sys.exc_info())
@ -55,15 +55,18 @@ def createguild():
c_name = request.form['name'] c_name = request.form['name']
try: try:
c_id = db.session.execute(db.insert(Topic).values( new_guild = db.session.execute(insert(Guild).values(
name = c_name, name = c_name,
display_name = request.form.get('display_name', c_name), display_name = request.form.get('display_name', c_name),
description = request.form['description'], description = request.form['description'],
owner_id = user.id owner_id = user.id
).returning(Topic.id)).fetchone() ).returning(Guild)).scalar()
if new_guild is None:
raise RuntimeError('no returning')
db.session.commit() db.session.commit()
return redirect(url_for('frontpage.topic_feed', name=c_name)) return redirect(new_guild.url())
except Exception: except Exception:
sys.excepthook(*sys.exc_info()) sys.excepthook(*sys.exc_info())
flash('Unable to create guild. It may already exist or you could not have permission to create new communities.') flash('Unable to create guild. It may already exist or you could not have permission to create new communities.')

View file

@ -1,11 +1,11 @@
from flask import Blueprint, abort, flash, request, redirect, render_template, url_for from flask import Blueprint, abort, flash, request, redirect, render_template, url_for
from flask_login import current_user, login_required from flask_login import current_user
from sqlalchemy import select from sqlalchemy import insert, select
from suou import Snowflake
from ..iding import id_from_b32l
from ..utils import is_b32l from ..utils import is_b32l
from ..models import Comment, db, User, Post, Topic from ..models import Comment, Guild, db, User, Post
from ..algorithms import user_timeline from ..algorithms import user_timeline
bp = Blueprint('detail', __name__) bp = Blueprint('detail', __name__)
@ -26,7 +26,7 @@ def user_profile(username):
@bp.route('/user/<username>') @bp.route('/user/<username>')
def user_profile_u(username: str): def user_profile_u(username: str):
if is_b32l(username): if is_b32l(username):
userid = id_from_b32l(username) userid = int(Snowflake.from_b32l(username))
user = db.session.execute(select(User).where(User.id == userid)).scalar() user = db.session.execute(select(User).where(User.id == userid)).scalar()
if user is not None: if user is not None:
username = user.username username = user.username
@ -42,9 +42,9 @@ def single_post_post_hook(p: Post):
if 'reply_to' in request.form: if 'reply_to' in request.form:
reply_to_id = request.form['reply_to'] reply_to_id = request.form['reply_to']
text = request.form['text'] text = request.form['text']
reply_to_p = db.session.execute(db.select(Post).where(Post.id == id_from_b32l(reply_to_id))).scalar() if reply_to_id else None reply_to_p = db.session.execute(db.select(Post).where(Post.id == int(Snowflake.from_b32l(reply_to_id)))).scalar() if reply_to_id else None
db.session.execute(db.insert(Comment).values( db.session.execute(insert(Comment).values(
author_id = current_user.id, author_id = current_user.id,
parent_post_id = p.id, parent_post_id = p.id,
parent_comment_id = reply_to_p, parent_comment_id = reply_to_p,
@ -57,7 +57,7 @@ def single_post_post_hook(p: Post):
@bp.route('/comments/<b32l:id>') @bp.route('/comments/<b32l:id>')
def post_detail(id: int): def post_detail(id: int):
post: Post | None = db.session.execute(db.select(Post).where(Post.id == id)).scalar() post: Post | None = db.session.execute(select(Post).where(Post.id == id)).scalar()
if post and post.url() != request.full_path: if post and post.url() != request.full_path:
return redirect(post.url()), 302 return redirect(post.url()), 302
@ -72,24 +72,24 @@ def user_post_detail(username: str, id: int, slug: str = ''):
if post is None or (post.is_removed and post.author != current_user): if post is None or (post.is_removed and post.author != current_user):
abort(404) abort(404)
if post.slug and not slug: if post.slug and slug != post.slug:
return redirect(url_for('detail.user_post_detail_slug', username=username, id=id, slug=post.slug)), 302 return redirect(post.url()), 302
if request.method == 'POST': if request.method == 'POST':
single_post_post_hook(post) single_post_post_hook(post)
return render_template('singlepost.html', p=post) return render_template('singlepost.html', p=post)
@bp.route('/+<topicname>/comments/<b32l:id>/', methods=['GET', 'POST']) @bp.route('/+<gname>/comments/<b32l:id>/', methods=['GET', 'POST'])
@bp.route('/+<topicname>/comments/<b32l:id>/<slug:slug>', methods=['GET', 'POST']) @bp.route('/+<gname>/comments/<b32l:id>/<slug:slug>', methods=['GET', 'POST'])
def topic_post_detail(topicname, id, slug=''): def guild_post_detail(gname, id, slug=''):
post: Post | None = db.session.execute(select(Post).join(Topic).where(Post.id == id, Topic.name == topicname)).scalar() post: Post | None = db.session.execute(select(Post).join(Guild).where(Post.id == id, Guild.name == gname)).scalar()
if post is None or (post.is_removed and post.author != current_user): if post is None or (post.is_removed and post.author != current_user):
abort(404) abort(404)
if post.slug and not slug: if post.slug and slug != post.slug:
return redirect(url_for('detail.topic_post_detail_slug', topicname=topicname, id=id, slug=post.slug)), 302 return redirect(post.url()), 302
if request.method == 'POST': if request.method == 'POST':
single_post_post_hook(post) single_post_post_hook(post)

View file

@ -4,7 +4,7 @@ from flask_login import current_user
from sqlalchemy import select from sqlalchemy import select
from ..search import SearchQuery from ..search import SearchQuery
from ..models import Post, db, Topic from ..models import Guild, Post, db
from ..algorithms import public_timeline, top_guilds_query, topic_timeline from ..algorithms import public_timeline, top_guilds_query, topic_timeline
bp = Blueprint('frontpage', __name__) bp = Blueprint('frontpage', __name__)
@ -32,19 +32,19 @@ def explore():
@bp.route('/+<name>/') @bp.route('/+<name>/')
def topic_feed(name): def guild_feed(name):
topic: Topic | None = db.session.execute(select(Topic).where(Topic.name == name)).scalar() guild: Guild | None = db.session.execute(select(Guild).where(Guild.name == name)).scalar()
if topic is None: if guild is None:
abort(404) abort(404)
posts = db.paginate(topic_timeline(name)) posts = db.paginate(topic_timeline(name))
return render_template( return render_template(
'feed.html', feed_type='guild', feed_title=f'{topic.display_name} (+{topic.name})', l=posts, topic=topic) 'feed.html', feed_type='guild', feed_title=f'{guild.display_name} (+{guild.name})', l=posts, guild=guild)
@bp.route('/r/<name>/') @bp.route('/r/<name>/')
def topic_feed_r(name): def guild_feed_r(name):
return redirect('/+' + name + '/'), 302 return redirect('/+' + name + '/'), 302

View file

@ -18,7 +18,7 @@ dependencies = [
"PsycoPG2-binary", "PsycoPG2-binary",
"libsass", "libsass",
"setuptools>=78.1.0", "setuptools>=78.1.0",
"sakuragasaki46-suou>=0.2.3" "sakuragasaki46-suou>=0.3.3"
] ]
requires-python = ">=3.10" requires-python = ">=3.10"
classifiers = [ classifiers = [