Compare commits
No commits in common. "c1c005cc4e94036ba5880d274b5f511d041a4bfa" and "22524c5920a26a5132d4b7bab1bfd5dbea78d05b" have entirely different histories.
c1c005cc4e
...
22524c5920
43 changed files with 289 additions and 1081 deletions
13
CHANGELOG.md
13
CHANGELOG.md
|
|
@ -1,18 +1,5 @@
|
|||
# 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
|
||||
|
||||
- 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
|
||||
|
|
|
|||
|
|
@ -1,92 +0,0 @@
|
|||
"""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 ###
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
"""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
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
"""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 ###
|
||||
|
|
@ -1,11 +1,9 @@
|
|||
|
||||
|
||||
import re
|
||||
from sqlite3 import ProgrammingError
|
||||
from typing import Any
|
||||
import warnings
|
||||
from flask import (
|
||||
Flask, g, render_template,
|
||||
Flask, g, redirect, render_template,
|
||||
request, send_from_directory, url_for
|
||||
)
|
||||
import os
|
||||
|
|
@ -13,38 +11,22 @@ import dotenv
|
|||
from flask_login import LoginManager
|
||||
from flask_wtf.csrf import CSRFProtect
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
from suou import Snowflake, ssv_list
|
||||
from werkzeug.routing import BaseConverter
|
||||
from sassutils.wsgi import SassMiddleware
|
||||
|
||||
from suou.configparse import ConfigOptions, ConfigValue
|
||||
|
||||
from freak.colors import color_themes, theme_classes
|
||||
|
||||
__version__ = '0.4.0-dev24'
|
||||
__version__ = '0.3.2'
|
||||
|
||||
APP_BASE_DIR = os.path.dirname(os.path.dirname(__file__))
|
||||
|
||||
if not dotenv.load_dotenv():
|
||||
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()
|
||||
dotenv.load_dotenv()
|
||||
|
||||
app = Flask(__name__)
|
||||
app.secret_key = app_config.secret_key
|
||||
app.config['SQLALCHEMY_DATABASE_URI'] = app_config.database_url
|
||||
app.secret_key = os.getenv('SECRET_KEY')
|
||||
app.config['SQLALCHEMY_DATABASE_URI'] = os.getenv('DATABASE_URL')
|
||||
app.config['JSONIFY_PRETTYPRINT_REGULAR'] = False
|
||||
|
||||
from .models import db, User, Post
|
||||
from .iding import id_from_b32l, id_to_b32l
|
||||
|
||||
# SASS
|
||||
app.wsgi_app = SassMiddleware(app.wsgi_app, dict(
|
||||
|
|
@ -57,9 +39,9 @@ class SlugConverter(BaseConverter):
|
|||
class B32lConverter(BaseConverter):
|
||||
regex = r'_?[a-z2-7]+'
|
||||
def to_url(self, value):
|
||||
return Snowflake(value).to_b32l()
|
||||
return id_to_b32l(value)
|
||||
def to_python(self, value):
|
||||
return Snowflake.from_b32l(value)
|
||||
return id_from_b32l(value)
|
||||
|
||||
app.url_map.converters['slug'] = SlugConverter
|
||||
app.url_map.converters['b32l'] = B32lConverter
|
||||
|
|
@ -79,40 +61,32 @@ PRIVATE_ASSETS = os.getenv('PRIVATE_ASSETS', '').split()
|
|||
@app.context_processor
|
||||
def _inject_variables():
|
||||
return {
|
||||
'app_name': app_config.app_name,
|
||||
'app_version': __version__,
|
||||
'domain_name': app_config.domain_name,
|
||||
'app_name': os.getenv('APP_NAME'),
|
||||
'domain_name': os.getenv('DOMAIN_NAME'),
|
||||
'url_for_css': (lambda name, **kwargs: url_for('static', filename=f'css/{name}.css', **kwargs)),
|
||||
'private_scripts': [x for x in app_config.private_assets if x.endswith('.js')],
|
||||
'private_scripts': [x for x in PRIVATE_ASSETS if x.endswith('.js')],
|
||||
'private_styles': [x for x in PRIVATE_ASSETS if x.endswith('.css')],
|
||||
'jquery_url': app_config.jquery_url,
|
||||
'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(),
|
||||
'colors': color_themes,
|
||||
'theme_classes': theme_classes
|
||||
'user_count': User.active_count()
|
||||
}
|
||||
|
||||
@login_manager.user_loader
|
||||
def _inject_user(userid):
|
||||
try:
|
||||
u = db.session.execute(select(User).where(User.id == userid)).scalar()
|
||||
if u is None or u.is_disabled:
|
||||
return None
|
||||
return u
|
||||
except SQLAlchemyError as e:
|
||||
warnings.warn(f'cannot retrieve user {userid} from db (exception: {e})', RuntimeWarning)
|
||||
return db.session.execute(select(User).where(User.id == userid)).scalar()
|
||||
except Exception:
|
||||
warnings.warn(f'cannot retrieve user {userid} from db', RuntimeWarning)
|
||||
g.no_user = True
|
||||
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)
|
||||
def error_db(body):
|
||||
g.no_user = True
|
||||
warnings.warn(f'No database access! (url is {redact_url_password(app.config['SQLALCHEMY_DATABASE_URI'])!r})', RuntimeWarning)
|
||||
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
|
||||
|
||||
@app.errorhandler(400)
|
||||
|
|
|
|||
|
|
@ -7,8 +7,7 @@ AJAX hooks for the website.
|
|||
|
||||
import re
|
||||
from flask import Blueprint, request
|
||||
from sqlalchemy import delete, insert, select
|
||||
from .models import Guild, db, User, Post, PostUpvote
|
||||
from .models import Topic, db, User, Post, PostUpvote
|
||||
from flask_login import current_user, login_required
|
||||
|
||||
bp = Blueprint('ajax', __name__)
|
||||
|
|
@ -19,7 +18,7 @@ def username_availability(username: str):
|
|||
is_valid = re.fullmatch('[a-z0-9_-]+', username) is not None
|
||||
|
||||
if is_valid:
|
||||
user = db.session.execute(select(User).where(User.username == username)).scalar()
|
||||
user = db.session.execute(db.select(User).where(User.username == username)).scalar()
|
||||
|
||||
is_available = user is None or user == current_user
|
||||
else:
|
||||
|
|
@ -33,10 +32,10 @@ def username_availability(username: str):
|
|||
|
||||
@bp.route('/guild_name_availability/<username>')
|
||||
def guild_name_availability(name: str):
|
||||
is_valid = re.fullmatch('[a-z0-9_-]+', name) is not None
|
||||
is_valid = re.fullmatch('[a-z0-9_-]+', username) is not None
|
||||
|
||||
if is_valid:
|
||||
gd = db.session.execute(select(Guild).where(Guild.name == name)).scalar()
|
||||
gd = db.session.execute(db.select(Topic).where(Topic.name == name)).scalar()
|
||||
|
||||
is_available = gd is None
|
||||
else:
|
||||
|
|
@ -52,19 +51,19 @@ def guild_name_availability(name: str):
|
|||
@login_required
|
||||
def post_upvote(id):
|
||||
o = request.form['o']
|
||||
p: Post | None = db.session.execute(select(Post).where(Post.id == id)).scalar()
|
||||
p: Post | None = db.session.execute(db.select(Post).where(Post.id == id)).scalar()
|
||||
|
||||
if p is None:
|
||||
return { 'status': 'fail', 'message': 'Post not found' }, 404
|
||||
|
||||
if o == '1':
|
||||
db.session.execute(delete(PostUpvote).where(PostUpvote.c.post_id == p.id, PostUpvote.c.voter_id == current_user.id, PostUpvote.c.is_downvote == True))
|
||||
db.session.execute(insert(PostUpvote).values(post_id = p.id, voter_id = current_user.id, is_downvote = False))
|
||||
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(delete(PostUpvote).where(PostUpvote.c.post_id == p.id, PostUpvote.c.voter_id == current_user.id))
|
||||
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(delete(PostUpvote).where(PostUpvote.c.post_id == p.id, PostUpvote.c.voter_id == current_user.id, PostUpvote.c.is_downvote == False))
|
||||
db.session.execute(insert(PostUpvote).values(post_id = p.id, voter_id = current_user.id, is_downvote = True))
|
||||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -2,33 +2,30 @@
|
|||
|
||||
from flask_login import current_user
|
||||
from sqlalchemy import func, select
|
||||
from .models import db, Post, Guild, User
|
||||
from .models import db, Post, Topic, User
|
||||
|
||||
def cuser() -> User:
|
||||
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():
|
||||
return select(Post).join(User, User.id == Post.author_id).where(
|
||||
Post.privacy == 0, User.not_suspended(), Post.not_removed(), User.has_not_blocked(Post.author_id, cuser_id())
|
||||
Post.privacy == 0, User.not_suspended(), Post.not_removed()
|
||||
).order_by(Post.created_at.desc())
|
||||
|
||||
def topic_timeline(gname):
|
||||
return select(Post).join(Guild).join(User, User.id == Post.author_id).where(
|
||||
Post.privacy == 0, Guild.name == gname, User.not_suspended(), Post.not_removed(), User.has_not_blocked(Post.author_id, cuser_id())
|
||||
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()
|
||||
).order_by(Post.created_at.desc())
|
||||
|
||||
def user_timeline(user_id):
|
||||
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(), User.has_not_blocked(Post.author_id, cuser_id())
|
||||
Post.visible_by(cuser()), User.id == user_id, User.not_suspended(), Post.not_removed()
|
||||
).order_by(Post.created_at.desc())
|
||||
|
||||
def top_guilds_query():
|
||||
q_post_count = func.count().label('post_count')
|
||||
qr = select(Guild, q_post_count)\
|
||||
.join(Post, Post.topic_id == Guild.id).group_by(Guild)\
|
||||
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())
|
||||
return qr
|
||||
|
||||
|
|
|
|||
28
freak/cli.py
28
freak/cli.py
|
|
@ -4,40 +4,22 @@ import argparse
|
|||
import os
|
||||
import subprocess
|
||||
|
||||
from sqlalchemy import create_engine, select
|
||||
from sqlalchemy.orm import Session
|
||||
from . import __version__ as version, app
|
||||
from .models import User, db
|
||||
from sqlalchemy import create_engine
|
||||
from . import __version__ as version
|
||||
from .models import 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():
|
||||
args = make_parser().parse_args()
|
||||
|
||||
engine = create_engine(os.getenv('DATABASE_URL'))
|
||||
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(create_engine(os.getenv('DATABASE_URL')))
|
||||
subprocess.Popen(['alembic', 'upgrade', 'head']).wait()
|
||||
print('Schema upgraded!')
|
||||
|
||||
if args.flush:
|
||||
cnt = 0
|
||||
with app.app_context():
|
||||
for u in db.session.execute(select(User)).scalars():
|
||||
u.recompute_karma()
|
||||
cnt += 1
|
||||
db.session.add(u)
|
||||
db.session.commit()
|
||||
print(f'Recomputed karma of {cnt} users')
|
||||
|
||||
print(f'Visit <https://{os.getenv("DOMAIN_NAME")}>')
|
||||
|
||||
|
|
|
|||
|
|
@ -1,39 +0,0 @@
|
|||
|
||||
|
||||
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
77
freak/dei.py
|
|
@ -1,77 +0,0 @@
|
|||
"""
|
||||
Utilities for Diversity, Equity, Inclusion
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
BRICKS = '@abcdefghijklmnopqrstuvwxyz+?-\'/'
|
||||
# legend @: space, -: literal, +: suffix (i.e. ae+r expands to ae/aer), ': literal, ?: unknown, /: separator
|
||||
|
||||
class Pronoun(int):
|
||||
PRESETS = {
|
||||
'hh': 'he/him',
|
||||
'sh': 'she/her',
|
||||
'tt': 'they/them',
|
||||
'ii': 'it/its',
|
||||
'hs': 'he/she',
|
||||
'ht': 'he/they',
|
||||
'hi': 'he/it',
|
||||
'shh': 'she/he',
|
||||
'st': 'she/they',
|
||||
'si': 'she/it',
|
||||
'th': 'they/he',
|
||||
'ts': 'they/she',
|
||||
'ti': 'they/it',
|
||||
}
|
||||
|
||||
UNSPECIFIED = 0
|
||||
|
||||
## presets from PronounDB
|
||||
## DO NOT TOUCH the values unless you know their exact correspondence!!
|
||||
## hint: Pronoun.from_short()
|
||||
HE = HE_HIM = 264
|
||||
SHE = SHE_HER = 275
|
||||
THEY = THEY_THEM = 660
|
||||
IT = IT_ITS = 297
|
||||
HE_SHE = 616
|
||||
HE_THEY = 648
|
||||
HE_IT = 296
|
||||
SHE_HE = 8467
|
||||
SHE_THEY = 657
|
||||
SHE_IT = 307
|
||||
THEY_HE = 276
|
||||
THEY_SHE = 628
|
||||
THEY_IT = 308
|
||||
ANY = 26049
|
||||
OTHER = 19047055
|
||||
ASK = 11873
|
||||
AVOID = NAME_ONLY = 4505281
|
||||
|
||||
def short(self) -> str:
|
||||
i = self
|
||||
s = ''
|
||||
while i > 0:
|
||||
s += BRICKS[i % 32]
|
||||
i >>= 5
|
||||
return s
|
||||
|
||||
def full(self):
|
||||
s = self.short()
|
||||
|
||||
if s in self.PRESETS:
|
||||
return self.PRESETS[s]
|
||||
|
||||
if '+' in s:
|
||||
s1, s2 = s.rsplit('+')
|
||||
s = s1 + '/' + s1 + s2
|
||||
|
||||
return s
|
||||
__str__ = full
|
||||
|
||||
@classmethod
|
||||
def from_short(self, s: str) -> Pronoun:
|
||||
i = 0
|
||||
for j, ch in enumerate(s):
|
||||
i += BRICKS.index(ch) << (5 * j)
|
||||
return Pronoun(i)
|
||||
|
|
@ -1,36 +1,78 @@
|
|||
|
||||
import markdown
|
||||
import re, markdown
|
||||
from markdown.inlinepatterns import InlineProcessor, SimpleTagInlineProcessor
|
||||
import xml.etree.ElementTree as etree
|
||||
from markupsafe import Markup
|
||||
|
||||
from suou import 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 '<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
|
||||
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(),
|
||||
PingExtension({'@': '/@', '+': '/+'})
|
||||
## XXX untested
|
||||
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 Snowflake(n).to_b32l()
|
||||
return id_to_b32l(n)
|
||||
|
||||
app.template_filter('b32l')(to_b32l)
|
||||
|
||||
@app.template_filter()
|
||||
def append(text, l: list):
|
||||
def append(text, l):
|
||||
l.append(text)
|
||||
return None
|
||||
|
||||
|
|
|
|||
|
|
@ -1,24 +1,17 @@
|
|||
"""
|
||||
DEPRECATED use suou.snowflake instead.
|
||||
|
||||
PSA: this module is for the LEGACY (v2) iding.
|
||||
|
||||
For the SIQ-based ID's, see suou.iding <https://github.com/sakuragasaki46/suou>.
|
||||
|
||||
The suou library also provides snowflake support.
|
||||
For the SIQ-based ID's (upcoming 0.4), see suou.iding <https://github.com/sakuragasaki46/suou>
|
||||
"""
|
||||
|
||||
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
|
||||
|
||||
|
|
@ -35,16 +28,14 @@ def new_id(*, from_date = None):
|
|||
((machine_counter := machine_counter + 1) % 1024)
|
||||
)
|
||||
|
||||
@deprecated('use suou.Snowflake.to_b32l() instead')
|
||||
def id_to_b32l(n: int) -> str:
|
||||
def id_to_b32l(n):
|
||||
return (
|
||||
'_' if n < 0 else ''
|
||||
) + base64.b32encode(
|
||||
(-n if n < 0 else n).to_bytes(10, 'big')
|
||||
).decode().lstrip('A').lower()
|
||||
|
||||
@deprecated('use suou.Snowflake.from_b32l() instead')
|
||||
def id_from_b32l(s: str) -> int:
|
||||
def id_from_b32l(s, *, n_bytes=10):
|
||||
return (-1 if s.startswith('_') else 1) * int.from_bytes(
|
||||
base64.b32decode(s.lstrip('_').upper().rjust(16, 'A').encode()), 'big'
|
||||
)
|
||||
|
|
|
|||
228
freak/models.py
228
freak/models.py
|
|
@ -4,20 +4,18 @@ from __future__ import annotations
|
|||
|
||||
from collections import namedtuple
|
||||
import datetime
|
||||
from functools import partial
|
||||
from functools import lru_cache
|
||||
from operator import or_
|
||||
from threading import Lock
|
||||
from sqlalchemy import Column, Integer, String, ForeignKey, UniqueConstraint, text, \
|
||||
from sqlalchemy import Column, String, ForeignKey, and_, text, \
|
||||
CheckConstraint, Date, DateTime, Boolean, func, BigInteger, \
|
||||
SmallInteger, select, update, Table
|
||||
from sqlalchemy.orm import Relationship, relationship
|
||||
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
|
||||
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 freak import app_config
|
||||
import os
|
||||
from .iding import new_id, id_to_b32l
|
||||
from .utils import age_and_days, get_remote_addr, timed_cache
|
||||
|
||||
|
||||
|
|
@ -27,27 +25,23 @@ USER_ACTIVE = 0
|
|||
USER_INACTIVE = 1
|
||||
USER_BANNED = 2
|
||||
|
||||
ReportReason = namedtuple('ReportReason', 'num_code code description extra', defaults=dict(extra=None))
|
||||
ReportReason = namedtuple('ReportReason', 'num_code code description')
|
||||
|
||||
post_report_reasons = [
|
||||
## emergency
|
||||
ReportReason(110, 'hate_speech', 'Extreme hate speech / terrorism'),
|
||||
ReportReason(121, 'csam', 'Child abuse or endangerment', extra=dict(suspend=True)),
|
||||
ReportReason(121, 'csam', 'Child abuse or endangerment'),
|
||||
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)'),
|
||||
## less urgent
|
||||
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'),
|
||||
ReportReason(112, 'lgbt_hate', 'Hate speech against LGBTQ+ or women'),
|
||||
ReportReason(150, 'security_exploit', 'Dangerous security exploit or violation'),
|
||||
ReportReason(190, 'false_information', 'False or deceiving information'),
|
||||
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))
|
||||
ReportReason(210, 'underage', 'Presence in violation of age limits (i.e. under 13, or minor in adult spaces)')
|
||||
]
|
||||
|
||||
REPORT_REASON_STRINGS = { **{x.num_code: x.description for x in post_report_reasons}, **{x.code: x.description for x in post_report_reasons} }
|
||||
|
|
@ -64,18 +58,20 @@ REPORT_UPDATE_ON_HOLD = 3
|
|||
|
||||
## END constants and enums
|
||||
|
||||
Base = declarative_base(app_config.domain_name, app_config.secret_key,
|
||||
snowflake_epoch=1577833200)
|
||||
Base = declarative_base()
|
||||
db = SQLAlchemy(model_class=Base)
|
||||
|
||||
CSI = create_session_interactively = partial(create_session, app_config.database_url)
|
||||
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
|
||||
|
||||
# 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')
|
||||
## TODO replace with suou.declarative_base() - upcoming 0.4
|
||||
class BaseModel(Base):
|
||||
__abstract__ = True
|
||||
|
||||
id = Column(BigInteger, primary_key=True, default=new_id)
|
||||
|
||||
## Many-to-many relationship keys for some reasons have to go
|
||||
|
|
@ -90,22 +86,10 @@ PostUpvote = Table(
|
|||
Column('is_downvote', Boolean, server_default=text('false'))
|
||||
)
|
||||
|
||||
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)
|
||||
)
|
||||
|
||||
|
||||
class User(Base):
|
||||
class User(BaseModel):
|
||||
__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()
|
||||
id = Column(BigInteger, primary_key=True, default=new_id, unique=True)
|
||||
|
||||
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)
|
||||
|
|
@ -118,10 +102,7 @@ class User(Base):
|
|||
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)
|
||||
|
||||
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'))
|
||||
# TODO add pronouns and biography (upcoming 0.4)
|
||||
|
||||
# moderation
|
||||
banned_at = Column(DateTime, nullable=True)
|
||||
|
|
@ -130,21 +111,17 @@ class User(Base):
|
|||
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
|
||||
## 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", 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
|
||||
|
||||
@property
|
||||
def is_disabled(self):
|
||||
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
|
||||
return self.banned_at is not None or self.is_disabled_by_user
|
||||
|
||||
@property
|
||||
def is_active(self):
|
||||
|
|
@ -174,7 +151,7 @@ class User(Base):
|
|||
"""
|
||||
## XXX change func name?
|
||||
return dict(
|
||||
id = Snowflake(self.id).to_b32l(),
|
||||
id = id_to_b32l(self.id),
|
||||
username = self.username,
|
||||
display_name = self.display_name,
|
||||
age = self.age()
|
||||
|
|
@ -182,18 +159,15 @@ class User(Base):
|
|||
)
|
||||
|
||||
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()
|
||||
|
||||
def can_create_guild(self):
|
||||
## TODO make guild creation requirements configurable
|
||||
return self.karma > 15 or self.is_administrator
|
||||
|
||||
can_create_community = deprecated('use .can_create_guild()')(can_create_guild)
|
||||
## deprecated alias!
|
||||
can_create_community = can_create_guild
|
||||
|
||||
def handle(self):
|
||||
return f'@{self.username}'
|
||||
|
|
@ -214,43 +188,10 @@ class User(Base):
|
|||
def not_suspended(cls):
|
||||
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):
|
||||
c = 0
|
||||
c += db.session.execute(select(func.count('*')).select_from(Post).where(Post.author == self)).scalar()
|
||||
c += db.session.execute(select(func.count('*')).select_from(PostUpvote).join(Post).where(Post.author == self, PostUpvote.c.is_downvote == False)).scalar()
|
||||
c -= db.session.execute(select(func.count('*')).select_from(PostUpvote).join(Post).where(Post.author == self, PostUpvote.c.is_downvote == True)).scalar()
|
||||
|
||||
self.karma = c
|
||||
|
||||
@timed_cache(60)
|
||||
def strike_count(self) -> int:
|
||||
return db.session.execute(select(func.count('*')).select_from(UserStrike).where(UserStrike.user_id == self.id)).scalar()
|
||||
|
||||
## END User
|
||||
|
||||
class Guild(Base):
|
||||
class Topic(BaseModel):
|
||||
__tablename__ = 'freak_topic'
|
||||
__table_args__ = (
|
||||
UniqueConstraint('id', name='topic_id_uniq'),
|
||||
)
|
||||
|
||||
id = snowflake_column()
|
||||
id = Column(BigInteger, primary_key=True, default=new_id, unique=True)
|
||||
|
||||
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)
|
||||
|
|
@ -258,12 +199,8 @@ class Guild(Base):
|
|||
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'"))
|
||||
# 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)
|
||||
privacy = Column(SmallInteger, server_default=text('0'))
|
||||
|
||||
# MUST NOT be filled in on post-0.2 instances
|
||||
legacy_id = Column(BigInteger, nullable=True)
|
||||
|
||||
def url(self):
|
||||
|
|
@ -273,56 +210,16 @@ class Guild(Base):
|
|||
return f'+{self.name}'
|
||||
|
||||
# utilities
|
||||
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())
|
||||
posts = relationship('Post', back_populates='topic')
|
||||
|
||||
|
||||
POST_TYPE_DEFAULT = 0
|
||||
POST_TYPE_LINK = 1
|
||||
|
||||
class Post(Base):
|
||||
class Post(BaseModel):
|
||||
__tablename__ = 'freak_post'
|
||||
__table_args__ = (
|
||||
UniqueConstraint('id', name='post_id_uniq'),
|
||||
)
|
||||
|
||||
id = snowflake_column()
|
||||
id = Column(BigInteger, primary_key=True, default=new_id, unique=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)
|
||||
|
|
@ -346,17 +243,16 @@ class Post(Base):
|
|||
|
||||
# utilities
|
||||
author: Relationship[User] = relationship("User", lazy='selectin', foreign_keys=[author_id])#, back_populates="posts")
|
||||
guild = relationship("Guild", back_populates="posts", lazy='selectin')
|
||||
topic = relationship("Topic", back_populates="posts", lazy='selectin')
|
||||
comments = relationship("Comment", back_populates="parent_post")
|
||||
upvoters = relationship("User", secondary=PostUpvote, back_populates='upvoted_posts')
|
||||
|
||||
def topic_or_user(self) -> Guild | User:
|
||||
return self.guild or self.author
|
||||
def topic_or_user(self) -> Topic | User:
|
||||
return self.topic or self.author
|
||||
|
||||
def url(self):
|
||||
return self.topic_or_user().url() + '/comments/' + Snowflake(self.id).to_b32l() + '/' + (self.slug or '')
|
||||
return self.topic_or_user().url() + '/comments/' + id_to_b32l(self.id) + '/' + (self.slug or '')
|
||||
|
||||
@not_implemented
|
||||
def generate_slug(self):
|
||||
return slugify.slugify(self.title, max_length=64)
|
||||
|
||||
|
|
@ -367,7 +263,7 @@ class Post(Base):
|
|||
def upvoted_by(self, user: User | AnonymousUserMixin | None):
|
||||
if not user or not user.is_authenticated:
|
||||
return 0
|
||||
v: PostUpvote | None = db.session.execute(select(PostUpvote.c).where(PostUpvote.c.voter_id == user.id, PostUpvote.c.post_id == self.id)).fetchone()
|
||||
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:
|
||||
return -1
|
||||
|
|
@ -378,7 +274,7 @@ class Post(Base):
|
|||
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:
|
||||
return f'/report/post/{Snowflake(self.id):l}'
|
||||
return '/report/post/' + id_to_b32l(self.id)
|
||||
|
||||
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()
|
||||
|
|
@ -401,13 +297,12 @@ class Post(Base):
|
|||
return or_(Post.author_id == user.id, Post.privacy.in_((0, 1)))
|
||||
|
||||
|
||||
class Comment(Base):
|
||||
class Comment(BaseModel):
|
||||
__tablename__ = 'freak_comment'
|
||||
__table_args__ = (
|
||||
UniqueConstraint('id', name='comment_id_uniq'),
|
||||
)
|
||||
|
||||
id = snowflake_column()
|
||||
# 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)
|
||||
|
||||
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)
|
||||
|
|
@ -418,7 +313,6 @@ class Comment(Base):
|
|||
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)
|
||||
|
|
@ -426,14 +320,15 @@ class Comment(Base):
|
|||
removed_reason = Column(SmallInteger, nullable=True)
|
||||
|
||||
author = relationship('User', foreign_keys=[author_id])#, back_populates='comments')
|
||||
parent_post: Relationship[Post] = relationship("Post", back_populates="comments", foreign_keys=[parent_post_id])
|
||||
parent_comment, child_comments = parent_children('comment', parent_remote_side=Wanted('id'))
|
||||
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")
|
||||
|
||||
def url(self):
|
||||
return self.parent_post.url() + f'/comment/{Snowflake(self.id):l}'
|
||||
return self.parent_post.url() + '/comment/' + id_to_b32l(self.id)
|
||||
|
||||
def report_url(self) -> str:
|
||||
return f'/report/comment/{Snowflake(self.id):l}'
|
||||
return '/report/comment/' + id_to_b32l(self.id)
|
||||
|
||||
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()
|
||||
|
|
@ -446,11 +341,9 @@ class Comment(Base):
|
|||
def not_removed(cls):
|
||||
return Post.removed_at == None
|
||||
|
||||
class PostReport(Base):
|
||||
class PostReport(BaseModel):
|
||||
__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)
|
||||
target_id = Column(BigInteger, nullable=False)
|
||||
|
|
@ -469,27 +362,6 @@ class PostReport(Base):
|
|||
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)
|
||||
issued_by = relationship(User, primaryjoin= lambda: User.id == UserStrike.issued_by_id)
|
||||
|
||||
# PostUpvote table is at the top !!
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@
|
|||
|
||||
from flask import Blueprint
|
||||
from flask_restx import Resource, Api
|
||||
from sqlalchemy import select
|
||||
from suou import Snowflake
|
||||
|
||||
from freak.iding import id_to_b32l
|
||||
|
||||
from ..models import Post, User, db
|
||||
|
||||
|
|
@ -21,31 +21,31 @@ class Nurupo(Resource):
|
|||
@rest.route('/user/<b32l:id>')
|
||||
class UserInfo(Resource):
|
||||
def get(self, id: int):
|
||||
u: User | None = db.session.execute(select(User).where(User.id == id)).scalar()
|
||||
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 = f'{Snowflake(u.id):l}',
|
||||
id = id_to_b32l(u.id),
|
||||
username = u.username,
|
||||
display_name = u.display_name,
|
||||
joined_at = u.joined_at.isoformat('T'),
|
||||
karma = u.karma,
|
||||
age = u.age()
|
||||
)
|
||||
return dict(users={f'{Snowflake(id):l}': uj})
|
||||
return dict(users={id_to_b32l(id): uj})
|
||||
|
||||
@rest.route('/post/<b32l:id>')
|
||||
class SinglePost(Resource):
|
||||
def get(self, id: int):
|
||||
p: Post | None = db.session.execute(select(Post).where(Post.id == id)).scalar()
|
||||
p: Post | None = db.session.execute(db.select(Post).where(Post.id == id)).scalar()
|
||||
if p is None:
|
||||
return dict(error='Not found'), 404
|
||||
pj = dict(
|
||||
id = f'{Snowflake(p.id):l}',
|
||||
id = id_to_b32l(p.id),
|
||||
title = p.title,
|
||||
author = p.author.simple_info(),
|
||||
to = p.topic_or_user().handle(),
|
||||
created_at = p.created_at.isoformat('T')
|
||||
)
|
||||
|
||||
return dict(posts={f'{Snowflake(id):l}': pj})
|
||||
return dict(posts={id_to_b32l(id): pj})
|
||||
|
|
@ -1,6 +1,14 @@
|
|||
(function(){
|
||||
"use strict";
|
||||
|
||||
// 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'
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
function attachUsernameInput(){
|
||||
|
|
@ -132,36 +140,9 @@
|
|||
}).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();
|
||||
|
|
|
|||
|
|
@ -5,26 +5,10 @@
|
|||
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: #a828ba
|
||||
--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-accent: #ff7300
|
||||
--light-success: #73af00
|
||||
--light-error: #e04433
|
||||
--light-canvas: #eaecee
|
||||
|
|
@ -34,21 +18,17 @@
|
|||
--dark-text-primary: #e8e8e8
|
||||
--dark-text-alt: #c0cad3
|
||||
--dark-border: #777
|
||||
--dark-accent: #ff7300
|
||||
--dark-success: #93cf00
|
||||
--dark-error: #e04433
|
||||
--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)
|
||||
|
||||
--text-primary: var(--light-text-primary)
|
||||
--text-alt: var(--light-text-alt)
|
||||
--border: var(--light-border)
|
||||
--accent: var(--light-accent)
|
||||
--success: var(--light-success)
|
||||
--error: var(--light-error)
|
||||
--canvas: var(--light-canvas)
|
||||
|
|
@ -60,77 +40,35 @@
|
|||
--text-primary: var(--dark-text-primary)
|
||||
--text-alt: var(--dark-text-alt)
|
||||
--border: var(--dark-border)
|
||||
--accent: var(--dark-accent)
|
||||
--success: var(--dark-success)
|
||||
--error: var(--dark-error)
|
||||
--canvas: var(--dark-canvas)
|
||||
--background: var(--dark-background)
|
||||
--bg-sharp: var(--dark-bg-sharp)
|
||||
|
||||
.color-scheme-light
|
||||
body.color-scheme-light
|
||||
--text-primary: var(--light-text-primary)
|
||||
--text-alt: var(--light-text-alt)
|
||||
--border: var(--light-border)
|
||||
--accent: var(--light-accent)
|
||||
--success: var(--light-success)
|
||||
--error: var(--light-error)
|
||||
--canvas: var(--light-canvas)
|
||||
--background: var(--light-background)
|
||||
--bg-sharp: var(--light-bg-sharp)
|
||||
|
||||
.color-scheme-dark
|
||||
body.color-scheme-dark
|
||||
--text-primary: var(--dark-text-primary)
|
||||
--text-alt: var(--dark-text-alt)
|
||||
--border: var(--dark-border)
|
||||
--accent: var(--dark-accent)
|
||||
--success: var(--dark-success)
|
||||
--error: var(--dark-error)
|
||||
--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
|
||||
|
|
|
|||
|
|
@ -29,7 +29,6 @@ blockquote
|
|||
ul
|
||||
margin: 4px 0
|
||||
padding: 0
|
||||
padding-inline-start: 1.5em
|
||||
> li
|
||||
margin: 0
|
||||
|
||||
|
|
|
|||
|
|
@ -54,11 +54,6 @@ 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
|
||||
|
|
@ -108,9 +103,6 @@ aside.card
|
|||
padding: 12px
|
||||
margin: -12px -12px 0 -12px
|
||||
position: relative
|
||||
a
|
||||
color: inherit
|
||||
text-decoration: underline
|
||||
> ul
|
||||
list-style: none
|
||||
margin: 0
|
||||
|
|
@ -140,7 +132,6 @@ ul.inline
|
|||
list-style: none
|
||||
padding: 0
|
||||
margin: 0
|
||||
display: inline
|
||||
> li
|
||||
display: inline
|
||||
&::before
|
||||
|
|
@ -148,30 +139,12 @@ 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
|
||||
|
|
@ -183,9 +156,6 @@ ul.message-options
|
|||
margin-left: 0
|
||||
margin-right: 3em
|
||||
|
||||
.message-options
|
||||
margin-bottom: 1em
|
||||
|
||||
.message-stats
|
||||
position: absolute
|
||||
left: -3em
|
||||
|
|
@ -302,7 +272,7 @@ button, [type="submit"], [type="reset"], [type="button"]
|
|||
|
||||
&.primary
|
||||
background-color: var(--accent)
|
||||
color: var(--background)
|
||||
color: var(--bg-main)
|
||||
|
||||
&[disabled]
|
||||
opacity: .5
|
||||
|
|
@ -328,27 +298,5 @@ button, [type="submit"], [type="reset"], [type="button"]
|
|||
width: 0
|
||||
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)
|
||||
|
||||
|
|
|
|||
|
|
@ -6,19 +6,11 @@
|
|||
.content-nav, .content-main
|
||||
width: 100%
|
||||
|
||||
ul.grid
|
||||
grid-template-columns: 1fr 1fr
|
||||
|
||||
.nomobile
|
||||
display: none
|
||||
|
||||
@media screen and (max-width: 960px)
|
||||
.header-username
|
||||
display: none
|
||||
|
||||
header.header
|
||||
padding: .5em .5em
|
||||
|
||||
.mini-search-bar
|
||||
display: none
|
||||
|
||||
|
|
@ -27,10 +19,3 @@
|
|||
|
||||
ul > li:has(.mini-search-bar)
|
||||
flex: unset
|
||||
|
||||
|
||||
// not mobile: //
|
||||
|
||||
@media screen and (min-width: 801px)
|
||||
.mobileonly
|
||||
display: none
|
||||
|
|
@ -6,13 +6,11 @@
|
|||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="stylesheet" type="text/css" href="/static/css/style.css">
|
||||
{% for private_style in private_styles %}
|
||||
<link rel="stylesheet" href="{{ private_style }}" />
|
||||
{% endfor %}
|
||||
<style>.done{opacity:.5}</style>
|
||||
</head>
|
||||
<body class="admin">
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1><a href="{{ url_for('admin.homepage') }}"><span class="faint">{{ app_name }}:</span> Admin</a></h1>
|
||||
<h1><a href="{{ url_for('admin.homepage') }}">{{ site_name }} Admin</a></h1>
|
||||
</div>
|
||||
<div class="content">
|
||||
{% for message in get_flashed_messages() %}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,9 @@
|
|||
{% extends "admin/admin_base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<ul class="grid">
|
||||
<ul>
|
||||
<li>
|
||||
<h2><a href="{{ url_for('admin.reports') }}">Reports</a></h2>
|
||||
</li>
|
||||
<li>
|
||||
<h2><a href="{{ url_for('admin.strikes') }}">Strikes</a></h2>
|
||||
<a href="{{ url_for('admin.reports') }}">Reports</a>
|
||||
</li>
|
||||
</ul>
|
||||
{% endblock %}
|
||||
|
|
@ -1,6 +1,5 @@
|
|||
{% 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 %}
|
||||
<h2>Report detail #{{ report.id }}</h2>
|
||||
|
|
@ -15,20 +14,10 @@
|
|||
{% else %}
|
||||
<p><i>Unknown media type</i></p>
|
||||
{% 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">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
|
||||
<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="2">Strike</button>
|
||||
{% endif %}
|
||||
<button type="submit" name="do" value="2">Put on hold</button>
|
||||
</form>
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -1,21 +0,0 @@
|
|||
{% 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 %}
|
||||
|
|
@ -4,7 +4,6 @@
|
|||
<meta charset="utf-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
{% from "macros/icon.html" import icon with context %}
|
||||
{% block title %}
|
||||
<title>{{ app_name }}</title>
|
||||
{% endblock %}
|
||||
|
|
@ -15,8 +14,6 @@
|
|||
This service is age-restricted; do not access if underage.
|
||||
More info: https://{{ domain_name }}/terms
|
||||
-->
|
||||
<meta name="og:site_name" content="{{ app_name }}" />
|
||||
<meta name="generator" content="{{ app_name }} {{ app_version }}" />
|
||||
<meta name="csrf_token" content="{{ csrf_token() }}">
|
||||
<link rel="stylesheet" href="{{ url_for_css('style') }}" />
|
||||
{# psa: icons url MUST be supplied by .env via PRIVATE_ASSETS= #}
|
||||
|
|
@ -26,7 +23,7 @@
|
|||
<link rel="icon" href="/favicon.ico" />
|
||||
<script src="{{ jquery_url }}"></script>
|
||||
</head>
|
||||
<body {% if current_user.color_theme %} class="{{ theme_classes(current_user.color_theme) }}"{% endif %}>
|
||||
<body>
|
||||
<header class="header">
|
||||
<h1><a href="/">{{ app_name }}</a></h1>
|
||||
<div class="metanav">
|
||||
|
|
@ -46,27 +43,29 @@
|
|||
{% if g.no_user %}
|
||||
<!-- no user -->
|
||||
{% elif current_user.is_authenticated %}
|
||||
<li>
|
||||
<a class="round border-accent" href="/create" title="Create a post">
|
||||
<li><a href="/create" title="Create a post">
|
||||
<i class="icon icon-add"></i>
|
||||
<span class="a11y">create</span>
|
||||
<span class="nomobile">New post</span>
|
||||
</a>
|
||||
</li>
|
||||
<li><a href="{{ current_user.url() }}" title="{{ current_user.handle() }}'s profile">{{ icon('profile')}}<span class="a11y">profile</span></a>
|
||||
</a></li><li><a href="{{ current_user.url() }}"
|
||||
title="@{{ current_user.username }}'s profile">
|
||||
<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.handle() }}</strong>
|
||||
<span class="header-username-karma">{{ icon('karma') }} {{ current_user.karma }} karma</span>
|
||||
</div></li>
|
||||
<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><a href="/logout" title="Log out">
|
||||
{{ icon('logout') }} <span class="a11y">log out</span>
|
||||
<i class="icon icon-logout"></i><span class="a11y">log out</span>
|
||||
</a></li>
|
||||
{% else %}
|
||||
<li><a href="/login" title="Log in">
|
||||
{{ icon('logout') }}<span class="a11y">log in</span>
|
||||
</a></li>
|
||||
<li><a href="/register" title="Register">
|
||||
{{ icon('join') }}<span class="a11y">register</span>
|
||||
<i class="icon icon-logout"></i>
|
||||
<span class="a11y">log in</span>
|
||||
</a></li><li><a href="/register" title="Register">
|
||||
<i class="icon icon-join"></i>
|
||||
<span class="a11y">register</span>
|
||||
</a></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
|
|
@ -103,9 +102,9 @@
|
|||
function changeAccentColorTime() {
|
||||
let hours = (new Date).getHours();
|
||||
if (hours < 6 || hours >= 19) {
|
||||
document.body.classList.add('night');
|
||||
document.body.style.setProperty('--accent', '#1871d8');
|
||||
} else {
|
||||
document.body.classList.remove('night');
|
||||
document.body.style.removeProperty('--accent');
|
||||
}
|
||||
}
|
||||
changeAccentColorTime();
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
{% extends "base.html" %}
|
||||
{% from "macros/feed.html" import feed_post, stop_scrolling, no_more_scrolling with context %}
|
||||
{% from "macros/title.html" import title_tag with context %}
|
||||
{% from "macros/nav.html" import nav_guild, nav_top_communities with context %}
|
||||
|
||||
{# set feed_title = 'For you' if feed_type == 'foryou' and not feed_title %}
|
||||
{% set feed_title = 'Explore' if feed_type == 'explore' and not feed_title #}
|
||||
|
|
@ -17,11 +16,13 @@
|
|||
|
||||
{% block nav %}
|
||||
{% if top_communities %}
|
||||
{% from "macros/nav.html" import nav_top_communities with context %}
|
||||
{{ nav_top_communities(top_communities) }}
|
||||
{% endif %}
|
||||
|
||||
{% if feed_type == 'guild' %}
|
||||
{{ nav_guild(guild) }}
|
||||
{% if feed_type == 'topic' %}
|
||||
{% from "macros/nav.html" import nav_topic with context %}
|
||||
{{ nav_topic(topic) }}
|
||||
{% endif %}
|
||||
|
||||
<aside class="card">
|
||||
|
|
|
|||
|
|
@ -4,8 +4,8 @@
|
|||
<div class="message-meta">Posted by <a href="{{ p.author.url() }}">@{{ p.author.username }}</a>
|
||||
{% if p.parent_post %}
|
||||
as a comment on <a href="{{ p.parent_post.url() }}">post “{{ p.parent_post.title }}”</a>
|
||||
{% elif p.guild %}
|
||||
on <a href="{{ p.guild.url() }}">{{ p.guild.handle() }}</a>
|
||||
{% elif p.topic %}
|
||||
on <a href="{{ p.topic.url() }}">+{{ p.topic.name }}</a>
|
||||
{% else %}
|
||||
on their user page
|
||||
{% endif %}
|
||||
|
|
|
|||
|
|
@ -5,8 +5,8 @@
|
|||
<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>
|
||||
<div class="message-meta">Posted by <a href="{{ p.author.url() }}">@{{ p.author.username }}</a>
|
||||
{% if p.guild %}
|
||||
on <a href="{{ p.guild.url() }}">{{ p.guild.handle() }}</a>
|
||||
{% if p.topic %}
|
||||
on <a href="{{ p.topic.url() }}">+{{ p.topic.name }}</a>
|
||||
{% else %}
|
||||
on their user page
|
||||
{% endif %}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
|
||||
{% macro nav_guild(gu) %}
|
||||
{% macro nav_topic(topic) %}
|
||||
<aside class="card">
|
||||
<h3>About <a href="{{ gu.url() }}">{{ gu.handle() }}</a></h3>
|
||||
<h3>About {{ topic.handle() }}</h3>
|
||||
<ul>
|
||||
<li><i class="icon icon-info" style="font-size:inherit"></i> {{ gu.description }}</li>
|
||||
<li><i class="icon icon-info" style="font-size:inherit"></i> {{ topic.description }}</li>
|
||||
<li>
|
||||
<strong>{{ gu.posts | count }}</strong> posts -
|
||||
<strong>{{ topic.posts | count }}</strong> posts -
|
||||
<strong>-</strong> subscribers
|
||||
</li>
|
||||
</ul>
|
||||
|
|
@ -14,11 +14,9 @@
|
|||
|
||||
{% macro nav_user(user) %}
|
||||
<aside class="card">
|
||||
<h3>About <a href="{{ user.url() }}">{{ user.display_name or user.handle() }}</a></h3>
|
||||
<h3>About {{ user.handle() }}</h3>
|
||||
<ul>
|
||||
{% if user.biography %}
|
||||
<li>{{ icon('info') }} {{ user.biography }}</li>
|
||||
{% endif %}
|
||||
<li>{# user.biography #}</li>
|
||||
</ul>
|
||||
</aside>
|
||||
{% endmacro %}
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@
|
|||
<label>{{ icon('calendar') }} Date of birth:</label>
|
||||
<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>
|
||||
<!-- 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 -->
|
||||
<!-- You must be 14 years old or older to register on {{ app_name }}. -->
|
||||
</div>
|
||||
{% if not current_user.is_anonymous %}
|
||||
<div>
|
||||
|
|
|
|||
|
|
@ -190,5 +190,3 @@ 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.
|
||||
{% endfilter %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
|
@ -3,22 +3,13 @@
|
|||
{% from "macros/feed.html" import single_comment, feed_upvote, comment_count with context %}
|
||||
{% from "macros/create.html" import comment_area with context %}
|
||||
{% from "macros/icon.html" import icon, callout with context %}
|
||||
{% from "macros/nav.html" import nav_guild, nav_user with context %}
|
||||
|
||||
{% block title %}
|
||||
{{ title_tag(p.title + '; from ' + p.topic_or_user().handle()) }}
|
||||
<meta name="og:title" content="{{ p.title }}" />
|
||||
{# meta name="og:description" coming in 0.4 #}
|
||||
{% if p.author %}
|
||||
<meta name="author" content="{{ p.author.display_name or p.author.username }}" />
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
{% block title %}{{ title_tag(p.title + '; from ' + p.topic_or_user().handle()) }}{% endblock %}
|
||||
|
||||
{% block nav %}
|
||||
{% if p.guild %}
|
||||
{{ nav_guild(p.guild) }}
|
||||
{% elif p.author %}
|
||||
{{ nav_user(p.author) }}
|
||||
{% if p.topic %}
|
||||
{% from "macros/nav.html" import nav_topic with context %}
|
||||
{{ nav_topic(p.topic) }}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
|
|
@ -29,15 +20,12 @@
|
|||
<h1 class="message-title">{{ p.title }}</h1>
|
||||
<div class="message-meta">
|
||||
Posted by <a href="{{ p.author.url() }}">@{{ p.author.username }}</a>
|
||||
{% if p.guild %}
|
||||
on <a href="{{ p.guild.url() }}">+{{ p.guild.name }}</a>
|
||||
{% if p.topic %}
|
||||
on <a href="{{ p.topic.url() }}">+{{ p.topic.name }}</a>
|
||||
{% else %}
|
||||
on their user page
|
||||
{% endif %}
|
||||
- <time datetime="{{ p.created_at.isoformat('T') }}">{{ p.created_at.strftime('%B %-d, %Y at %H:%M') }}</time>
|
||||
{% if p.privacy == 1 %}
|
||||
- {{ icon('link_post') }} Unlisted
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if current_user.is_administrator and p.report_count() %}
|
||||
{% call callout() %}
|
||||
|
|
@ -57,6 +45,7 @@
|
|||
{{ feed_upvote(p.id, p.upvotes(), p.upvoted_by(current_user)) }}
|
||||
{{ comment_count(p.comments | count) }}
|
||||
</div>
|
||||
</div>
|
||||
<ul class="message-options inline">
|
||||
{% if p.author == current_user %}
|
||||
<li><a href="/edit/post/{{ p.id|to_b32l }}"><i class="icon icon-edit"></i> Edit</a></li>
|
||||
|
|
@ -64,8 +53,6 @@
|
|||
<li><a href="{{ p.report_url() }}"><i class="icon icon-report"></i> Report</a></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{{ comment_area(p.url()) }}
|
||||
<div class="comment-section">
|
||||
<ul>
|
||||
|
|
|
|||
|
|
@ -2,24 +2,11 @@
|
|||
{% from "macros/feed.html" import feed_post, stop_scrolling, no_more_scrolling with context %}
|
||||
{% from "macros/title.html" import title_tag with context %}
|
||||
{% from "macros/icon.html" import icon, callout with context %}
|
||||
{% from "macros/nav.html" import nav_user with context %}
|
||||
|
||||
{% block title %}{{ title_tag(user.handle() + '’s content') }}{% endblock %}
|
||||
|
||||
{% block heading %}
|
||||
<h2>{{ user.handle() }}</h2>
|
||||
<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 %}
|
||||
|
||||
{% block nav %}
|
||||
{{ nav_user(user) }}
|
||||
{% if user == current_user %}
|
||||
<a href="/settings"><button class="card">{{ icon('settings') }} Settings</button></a>
|
||||
{% endif %}
|
||||
<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>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
|
|
|||
|
|
@ -1,52 +0,0 @@
|
|||
{% 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 %}
|
||||
|
|
@ -2,6 +2,7 @@
|
|||
import sys
|
||||
from flask import Blueprint, render_template, __version__ as flask_version
|
||||
from sqlalchemy import __version__ as sa_version
|
||||
from .. import __version__ as app_version
|
||||
|
||||
bp = Blueprint('about', __name__)
|
||||
|
||||
|
|
@ -10,7 +11,8 @@ def about():
|
|||
return render_template('about.html',
|
||||
flask_version=flask_version,
|
||||
sa_version=sa_version,
|
||||
python_version=sys.version.split()[0]
|
||||
python_version=sys.version.split()[0],
|
||||
app_version=app_version
|
||||
)
|
||||
|
||||
@bp.route('/terms/')
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@ import os, sys
|
|||
import re
|
||||
import datetime
|
||||
from typing import Mapping
|
||||
from flask import Blueprint, abort, render_template, request, redirect, flash
|
||||
from flask_login import login_required, login_user, logout_user, current_user
|
||||
from flask import Blueprint, render_template, request, redirect, flash
|
||||
from flask_login import login_user, logout_user, current_user
|
||||
from ..models import REPORT_REASONS, db, User
|
||||
from ..utils import age_and_days
|
||||
from sqlalchemy import select, insert
|
||||
|
|
@ -101,30 +101,3 @@ def register():
|
|||
|
||||
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')
|
||||
|
||||
|
|
|
|||
|
|
@ -3,13 +3,11 @@
|
|||
import datetime
|
||||
from functools import wraps
|
||||
from typing import Callable
|
||||
import warnings
|
||||
from flask import Blueprint, abort, redirect, render_template, request, url_for
|
||||
from flask_login import current_user
|
||||
from sqlalchemy import insert, select, update
|
||||
from suou import additem, not_implemented
|
||||
from sqlalchemy import select, update
|
||||
|
||||
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
|
||||
from ..models import REPORT_REASON_STRINGS, REPORT_UPDATE_COMPLETE, REPORT_UPDATE_ON_HOLD, REPORT_UPDATE_REJECTED, Comment, Post, PostReport, User, db
|
||||
|
||||
bp = Blueprint('admin', __name__)
|
||||
|
||||
|
|
@ -24,96 +22,36 @@ def admin_required(func: Callable):
|
|||
return func(**ka)
|
||||
return wrapper
|
||||
|
||||
|
||||
TARGET_TYPES = {
|
||||
Post: REPORT_TARGET_POST,
|
||||
Comment: REPORT_TARGET_COMMENT
|
||||
}
|
||||
|
||||
def remove_content(target, reason_code: int):
|
||||
def accept_report(target, source: PostReport):
|
||||
if isinstance(target, Post):
|
||||
target.removed_at = datetime.datetime.now()
|
||||
target.removed_by_id = current_user.id
|
||||
target.removed_reason = reason_code
|
||||
target.removed_reason = source.reason_code
|
||||
elif isinstance(target, Comment):
|
||||
target.removed_at = datetime.datetime.now()
|
||||
target.removed_by_id = current_user.id
|
||||
target.removed_reason = reason_code
|
||||
target.removed_reason = source.reason_code
|
||||
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
|
||||
db.session.add(source)
|
||||
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):
|
||||
source.update_status = REPORT_UPDATE_REJECTED
|
||||
db.session.add(source)
|
||||
db.session.commit()
|
||||
|
||||
|
||||
@additem(REPORT_ACTIONS, '3')
|
||||
def withhold_report(target, source: PostReport):
|
||||
source.update_status = REPORT_UPDATE_ON_HOLD
|
||||
db.session.add(source)
|
||||
db.session.commit()
|
||||
|
||||
|
||||
@additem(REPORT_ACTIONS, '4')
|
||||
@not_implemented()
|
||||
def escalate_report(target, source: PostReport):
|
||||
...
|
||||
|
||||
## END report actions
|
||||
REPORT_ACTIONS = {
|
||||
'1': accept_report,
|
||||
'0': reject_report,
|
||||
'2': withhold_report
|
||||
}
|
||||
|
||||
@bp.route('/admin/')
|
||||
@admin_required
|
||||
|
|
@ -139,10 +77,3 @@ def report_detail(id: int):
|
|||
return redirect(url_for('admin.reports'))
|
||||
return render_template('admin/admin_report_detail.html', report=report,
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -4,8 +4,8 @@ import sys
|
|||
import datetime
|
||||
from flask import Blueprint, abort, redirect, flash, render_template, request, url_for
|
||||
from flask_login import current_user, login_required
|
||||
from sqlalchemy import insert, select
|
||||
from ..models import User, db, Guild, Post
|
||||
from sqlalchemy import insert
|
||||
from ..models import User, db, Topic, Post
|
||||
|
||||
bp = Blueprint('create', __name__)
|
||||
|
||||
|
|
@ -14,20 +14,20 @@ bp = Blueprint('create', __name__)
|
|||
def create():
|
||||
user: User = current_user
|
||||
if request.method == 'POST' and 'title' in request.form:
|
||||
gname = request.form['to']
|
||||
if gname:
|
||||
guild: Guild | None = db.session.execute(select(Guild).where(Guild.name == gname)).scalar()
|
||||
if guild is None:
|
||||
flash(f'Guild +{gname} not found or inaccessible, posting to your user page instead')
|
||||
topic_name = request.form['to']
|
||||
if topic_name:
|
||||
topic: Topic | None = db.session.execute(db.select(Topic).where(Topic.name == topic_name)).scalar()
|
||||
if topic is None:
|
||||
flash(f'Topic +{topic_name} not found, posting to your user page instead')
|
||||
else:
|
||||
guild = None
|
||||
topic = None
|
||||
title = request.form['title']
|
||||
text = request.form['text']
|
||||
privacy = int(request.form.get('privacy', '0'))
|
||||
try:
|
||||
new_post: Post = db.session.execute(insert(Post).values(
|
||||
author_id = user.id,
|
||||
topic_id = guild.id if guild else None,
|
||||
topic_id = topic.id if topic else None,
|
||||
created_at = datetime.datetime.now(),
|
||||
privacy = privacy,
|
||||
title = title,
|
||||
|
|
@ -35,7 +35,7 @@ def create():
|
|||
).returning(Post.id)).fetchone()
|
||||
|
||||
db.session.commit()
|
||||
flash(f'Published on {guild.handle() if guild else user.handle()}')
|
||||
flash(f'Published on {'+' + topic_name if topic_name else '@' + user.username}')
|
||||
return redirect(url_for('detail.post_detail', id=new_post.id))
|
||||
except Exception as e:
|
||||
sys.excepthook(*sys.exc_info())
|
||||
|
|
@ -55,18 +55,15 @@ def createguild():
|
|||
|
||||
c_name = request.form['name']
|
||||
try:
|
||||
new_guild = db.session.execute(insert(Guild).values(
|
||||
c_id = db.session.execute(db.insert(Topic).values(
|
||||
name = c_name,
|
||||
display_name = request.form.get('display_name', c_name),
|
||||
description = request.form['description'],
|
||||
owner_id = user.id
|
||||
).returning(Guild)).scalar()
|
||||
|
||||
if new_guild is None:
|
||||
raise RuntimeError('no returning')
|
||||
).returning(Topic.id)).fetchone()
|
||||
|
||||
db.session.commit()
|
||||
return redirect(new_guild.url())
|
||||
return redirect(url_for('frontpage.topic_feed', name=c_name))
|
||||
except Exception:
|
||||
sys.excepthook(*sys.exc_info())
|
||||
flash('Unable to create guild. It may already exist or you could not have permission to create new communities.')
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
|
||||
from flask import Blueprint, abort, flash, request, redirect, render_template, url_for
|
||||
from flask_login import current_user
|
||||
from sqlalchemy import insert, select
|
||||
from suou import Snowflake
|
||||
from flask_login import current_user, login_required
|
||||
from sqlalchemy import select
|
||||
|
||||
from ..iding import id_from_b32l
|
||||
from ..utils import is_b32l
|
||||
from ..models import Comment, Guild, db, User, Post
|
||||
from ..models import Comment, db, User, Post, Topic
|
||||
from ..algorithms import user_timeline
|
||||
|
||||
bp = Blueprint('detail', __name__)
|
||||
|
|
@ -26,7 +26,7 @@ def user_profile(username):
|
|||
@bp.route('/user/<username>')
|
||||
def user_profile_u(username: str):
|
||||
if is_b32l(username):
|
||||
userid = int(Snowflake.from_b32l(username))
|
||||
userid = id_from_b32l(username)
|
||||
user = db.session.execute(select(User).where(User.id == userid)).scalar()
|
||||
if user is not None:
|
||||
username = user.username
|
||||
|
|
@ -42,9 +42,9 @@ def single_post_post_hook(p: Post):
|
|||
if 'reply_to' in request.form:
|
||||
reply_to_id = request.form['reply_to']
|
||||
text = request.form['text']
|
||||
reply_to_p = db.session.execute(db.select(Post).where(Post.id == int(Snowflake.from_b32l(reply_to_id)))).scalar() if reply_to_id else None
|
||||
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
|
||||
|
||||
db.session.execute(insert(Comment).values(
|
||||
db.session.execute(db.insert(Comment).values(
|
||||
author_id = current_user.id,
|
||||
parent_post_id = p.id,
|
||||
parent_comment_id = reply_to_p,
|
||||
|
|
@ -57,7 +57,7 @@ def single_post_post_hook(p: Post):
|
|||
|
||||
@bp.route('/comments/<b32l:id>')
|
||||
def post_detail(id: int):
|
||||
post: Post | None = db.session.execute(select(Post).where(Post.id == id)).scalar()
|
||||
post: Post | None = db.session.execute(db.select(Post).where(Post.id == id)).scalar()
|
||||
|
||||
if post and post.url() != request.full_path:
|
||||
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):
|
||||
abort(404)
|
||||
|
||||
if post.slug and slug != post.slug:
|
||||
return redirect(post.url()), 302
|
||||
if post.slug and not slug:
|
||||
return redirect(url_for('detail.user_post_detail_slug', username=username, id=id, slug=post.slug)), 302
|
||||
|
||||
if request.method == 'POST':
|
||||
single_post_post_hook(post)
|
||||
|
||||
return render_template('singlepost.html', p=post)
|
||||
|
||||
@bp.route('/+<gname>/comments/<b32l:id>/', methods=['GET', 'POST'])
|
||||
@bp.route('/+<gname>/comments/<b32l:id>/<slug:slug>', methods=['GET', 'POST'])
|
||||
def guild_post_detail(gname, id, slug=''):
|
||||
post: Post | None = db.session.execute(select(Post).join(Guild).where(Post.id == id, Guild.name == gname)).scalar()
|
||||
@bp.route('/+<topicname>/comments/<b32l:id>/', methods=['GET', 'POST'])
|
||||
@bp.route('/+<topicname>/comments/<b32l:id>/<slug:slug>', methods=['GET', 'POST'])
|
||||
def topic_post_detail(topicname, id, slug=''):
|
||||
post: Post | None = db.session.execute(select(Post).join(Topic).where(Post.id == id, Topic.name == topicname)).scalar()
|
||||
|
||||
if post is None or (post.is_removed and post.author != current_user):
|
||||
abort(404)
|
||||
|
||||
if post.slug and slug != post.slug:
|
||||
return redirect(post.url()), 302
|
||||
if post.slug and not slug:
|
||||
return redirect(url_for('detail.topic_post_detail_slug', topicname=topicname, id=id, slug=post.slug)), 302
|
||||
|
||||
if request.method == 'POST':
|
||||
single_post_post_hook(post)
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@
|
|||
import datetime
|
||||
from flask import Blueprint, abort, flash, redirect, render_template, request
|
||||
from flask_login import current_user, login_required
|
||||
from sqlalchemy import select
|
||||
|
||||
from ..models import Post, db
|
||||
|
||||
|
|
@ -14,7 +13,7 @@ bp = Blueprint('edit', __name__)
|
|||
@bp.route('/edit/post/<b32l:id>', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def edit_post(id):
|
||||
p: Post | None = db.session.execute(select(Post).where(Post.id == id)).scalar()
|
||||
p: Post | None = db.session.execute(db.select(Post).where(Post.id == id)).scalar()
|
||||
|
||||
if p is None:
|
||||
abort(404)
|
||||
|
|
|
|||
|
|
@ -1,10 +1,8 @@
|
|||
|
||||
from flask import Blueprint, render_template, redirect, abort, request
|
||||
from flask_login import current_user
|
||||
from sqlalchemy import select
|
||||
|
||||
from ..search import SearchQuery
|
||||
from ..models import Guild, Post, db
|
||||
from ..models import Post, db, Topic
|
||||
from ..algorithms import public_timeline, top_guilds_query, topic_timeline
|
||||
|
||||
bp = Blueprint('frontpage', __name__)
|
||||
|
|
@ -32,19 +30,19 @@ def explore():
|
|||
|
||||
|
||||
@bp.route('/+<name>/')
|
||||
def guild_feed(name):
|
||||
guild: Guild | None = db.session.execute(select(Guild).where(Guild.name == name)).scalar()
|
||||
def topic_feed(name):
|
||||
topic: Topic = db.session.execute(db.select(Topic).where(Topic.name == name)).scalar()
|
||||
|
||||
if guild is None:
|
||||
if topic is None:
|
||||
abort(404)
|
||||
|
||||
posts = db.paginate(topic_timeline(name))
|
||||
|
||||
return render_template(
|
||||
'feed.html', feed_type='guild', feed_title=f'{guild.display_name} (+{guild.name})', l=posts, guild=guild)
|
||||
'feed.html', feed_type='topic', feed_title=f'{topic.display_name} (+{topic.name})', l=posts, topic=topic)
|
||||
|
||||
@bp.route('/r/<name>/')
|
||||
def guild_feed_r(name):
|
||||
def topic_feed_r(name):
|
||||
return redirect('/+' + name + '/'), 302
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ dependencies = [
|
|||
"PsycoPG2-binary",
|
||||
"libsass",
|
||||
"setuptools>=78.1.0",
|
||||
"sakuragasaki46-suou>=0.3.3"
|
||||
"sakuragasaki46-suou>=0.2.3"
|
||||
]
|
||||
requires-python = ">=3.10"
|
||||
classifiers = [
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue