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
|
# 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
|
## 0.3.2
|
||||||
|
|
||||||
- Fixed administrator users not being able to create +guilds
|
- 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 sqlite3 import ProgrammingError
|
||||||
from typing import Any
|
|
||||||
import warnings
|
import warnings
|
||||||
from flask import (
|
from flask import (
|
||||||
Flask, g, render_template,
|
Flask, g, redirect, render_template,
|
||||||
request, send_from_directory, url_for
|
request, send_from_directory, url_for
|
||||||
)
|
)
|
||||||
import os
|
import os
|
||||||
|
|
@ -13,38 +11,22 @@ 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
|
||||||
|
|
||||||
from suou.configparse import ConfigOptions, ConfigValue
|
__version__ = '0.3.2'
|
||||||
|
|
||||||
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():
|
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()
|
|
||||||
|
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
app.secret_key = app_config.secret_key
|
app.secret_key = os.getenv('SECRET_KEY')
|
||||||
app.config['SQLALCHEMY_DATABASE_URI'] = app_config.database_url
|
app.config['SQLALCHEMY_DATABASE_URI'] = os.getenv('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(
|
||||||
|
|
@ -57,9 +39,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 Snowflake(value).to_b32l()
|
return id_to_b32l(value)
|
||||||
def to_python(self, 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['slug'] = SlugConverter
|
||||||
app.url_map.converters['b32l'] = B32lConverter
|
app.url_map.converters['b32l'] = B32lConverter
|
||||||
|
|
@ -79,40 +61,32 @@ PRIVATE_ASSETS = os.getenv('PRIVATE_ASSETS', '').split()
|
||||||
@app.context_processor
|
@app.context_processor
|
||||||
def _inject_variables():
|
def _inject_variables():
|
||||||
return {
|
return {
|
||||||
'app_name': app_config.app_name,
|
'app_name': os.getenv('APP_NAME'),
|
||||||
'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 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')],
|
'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(),
|
'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:
|
||||||
u = db.session.execute(select(User).where(User.id == userid)).scalar()
|
return db.session.execute(select(User).where(User.id == userid)).scalar()
|
||||||
if u is None or u.is_disabled:
|
except Exception:
|
||||||
return None
|
warnings.warn(f'cannot retrieve user {userid} from db', RuntimeWarning)
|
||||||
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 {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
|
return render_template('500.html'), 500
|
||||||
|
|
||||||
@app.errorhandler(400)
|
@app.errorhandler(400)
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,7 @@ AJAX hooks for the website.
|
||||||
|
|
||||||
import re
|
import re
|
||||||
from flask import Blueprint, request
|
from flask import Blueprint, request
|
||||||
from sqlalchemy import delete, insert, select
|
from .models import Topic, db, User, Post, PostUpvote
|
||||||
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__)
|
||||||
|
|
@ -19,7 +18,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(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
|
is_available = user is None or user == current_user
|
||||||
else:
|
else:
|
||||||
|
|
@ -33,10 +32,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_-]+', name) is not None
|
is_valid = re.fullmatch('[a-z0-9_-]+', username) is not None
|
||||||
|
|
||||||
if is_valid:
|
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
|
is_available = gd is None
|
||||||
else:
|
else:
|
||||||
|
|
@ -52,19 +51,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(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:
|
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(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.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.insert(PostUpvote).values(post_id = p.id, voter_id = current_user.id, is_downvote = False))
|
||||||
elif o == '0':
|
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':
|
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(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(insert(PostUpvote).values(post_id = p.id, voter_id = current_user.id, is_downvote = True))
|
db.session.execute(db.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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,33 +2,30 @@
|
||||||
|
|
||||||
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, Guild, User
|
from .models import db, Post, Topic, 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(), User.has_not_blocked(Post.author_id, cuser_id())
|
Post.privacy == 0, User.not_suspended(), Post.not_removed()
|
||||||
).order_by(Post.created_at.desc())
|
).order_by(Post.created_at.desc())
|
||||||
|
|
||||||
def topic_timeline(gname):
|
def topic_timeline(topic_name):
|
||||||
return select(Post).join(Guild).join(User, User.id == Post.author_id).where(
|
return select(Post).join(Topic).join(User, User.id == Post.author_id).where(
|
||||||
Post.privacy == 0, Guild.name == gname, User.not_suspended(), Post.not_removed(), User.has_not_blocked(Post.author_id, cuser_id())
|
Post.privacy == 0, Topic.name == topic_name, User.not_suspended(), Post.not_removed()
|
||||||
).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(), 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())
|
).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(Guild, q_post_count)\
|
qr = select(Topic, q_post_count)\
|
||||||
.join(Post, Post.topic_id == Guild.id).group_by(Guild)\
|
.join(Post, Post.topic_id == Topic.id).group_by(Topic)\
|
||||||
.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
|
||||||
|
|
||||||
|
|
|
||||||
28
freak/cli.py
28
freak/cli.py
|
|
@ -4,40 +4,22 @@ import argparse
|
||||||
import os
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
|
|
||||||
from sqlalchemy import create_engine, select
|
from sqlalchemy import create_engine
|
||||||
from sqlalchemy.orm import Session
|
from . import __version__ as version
|
||||||
from . import __version__ as version, app
|
from .models import db
|
||||||
from .models import User, db
|
|
||||||
|
|
||||||
def make_parser():
|
def make_parser():
|
||||||
parser = argparse.ArgumentParser()
|
parser = argparse.ArgumentParser()
|
||||||
parser.add_argument('--version', '-v', action='version', version=version)
|
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('--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
|
return parser
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
args = make_parser().parse_args()
|
args = make_parser().parse_args()
|
||||||
|
|
||||||
engine = create_engine(os.getenv('DATABASE_URL'))
|
|
||||||
if args.upgrade:
|
if args.upgrade:
|
||||||
ret_code = subprocess.Popen(['alembic', 'upgrade', 'head']).wait()
|
db.metadata.create_all(create_engine(os.getenv('DATABASE_URL')))
|
||||||
if ret_code != 0:
|
subprocess.Popen(['alembic', 'upgrade', 'head']).wait()
|
||||||
print(f'Schema upgrade failed (code: {ret_code})')
|
|
||||||
exit(ret_code)
|
|
||||||
# if the alembic/versions folder is empty
|
|
||||||
db.metadata.create_all(engine)
|
|
||||||
print('Schema upgraded!')
|
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")}>')
|
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 markupsafe import Markup
|
||||||
|
|
||||||
from suou import Snowflake
|
|
||||||
from suou.markdown import StrikethroughExtension, SpoilerExtension, PingExtension
|
|
||||||
|
|
||||||
from . import app
|
from . import app
|
||||||
|
|
||||||
# make spoilers prevail over blockquotes
|
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()
|
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(),
|
||||||
PingExtension({'@': '/@', '+': '/+'})
|
## XXX untested
|
||||||
|
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 Snowflake(n).to_b32l()
|
return id_to_b32l(n)
|
||||||
|
|
||||||
app.template_filter('b32l')(to_b32l)
|
|
||||||
|
|
||||||
@app.template_filter()
|
@app.template_filter()
|
||||||
def append(text, l: list):
|
def append(text, l):
|
||||||
l.append(text)
|
l.append(text)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,17 @@
|
||||||
"""
|
"""
|
||||||
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, see suou.iding <https://github.com/sakuragasaki46/suou>.
|
For the SIQ-based ID's (upcoming 0.4), 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
|
||||||
|
|
||||||
|
|
@ -35,16 +28,14 @@ def new_id(*, from_date = None):
|
||||||
((machine_counter := machine_counter + 1) % 1024)
|
((machine_counter := machine_counter + 1) % 1024)
|
||||||
)
|
)
|
||||||
|
|
||||||
@deprecated('use suou.Snowflake.to_b32l() instead')
|
def id_to_b32l(n):
|
||||||
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()
|
||||||
|
|
||||||
@deprecated('use suou.Snowflake.from_b32l() instead')
|
def id_from_b32l(s, *, n_bytes=10):
|
||||||
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'
|
||||||
)
|
)
|
||||||
|
|
|
||||||
232
freak/models.py
232
freak/models.py
|
|
@ -4,20 +4,18 @@ from __future__ import annotations
|
||||||
|
|
||||||
from collections import namedtuple
|
from collections import namedtuple
|
||||||
import datetime
|
import datetime
|
||||||
from functools import partial
|
from functools import lru_cache
|
||||||
from operator import or_
|
from operator import or_
|
||||||
from threading import Lock
|
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, \
|
CheckConstraint, Date, DateTime, Boolean, func, BigInteger, \
|
||||||
SmallInteger, select, update, Table
|
SmallInteger, select, insert, update, create_engine, Table
|
||||||
from sqlalchemy.orm import Relationship, relationship
|
from sqlalchemy.orm import Relationship, declarative_base, 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 freak import app_config
|
from .iding import new_id, id_to_b32l
|
||||||
from .utils import age_and_days, get_remote_addr, timed_cache
|
from .utils import age_and_days, get_remote_addr, timed_cache
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -27,27 +25,23 @@ USER_ACTIVE = 0
|
||||||
USER_INACTIVE = 1
|
USER_INACTIVE = 1
|
||||||
USER_BANNED = 2
|
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 = [
|
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', extra=dict(suspend=True)),
|
ReportReason(121, 'csam', 'Child abuse or endangerment'),
|
||||||
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)'),
|
||||||
## 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(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(123, 'copyviol', 'This is my creation and someone else is using it without my permission/license'),
|
ReportReason(210, 'underage', 'Presence in violation of age limits (i.e. under 13, or minor in adult spaces)')
|
||||||
## 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} }
|
||||||
|
|
@ -64,18 +58,20 @@ REPORT_UPDATE_ON_HOLD = 3
|
||||||
|
|
||||||
## END constants and enums
|
## END constants and enums
|
||||||
|
|
||||||
Base = declarative_base(app_config.domain_name, app_config.secret_key,
|
Base = declarative_base()
|
||||||
snowflake_epoch=1577833200)
|
|
||||||
db = SQLAlchemy(model_class=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
|
## TODO replace with suou.declarative_base() - upcoming 0.4
|
||||||
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
|
||||||
|
|
@ -90,22 +86,10 @@ PostUpvote = Table(
|
||||||
Column('is_downvote', Boolean, server_default=text('false'))
|
Column('is_downvote', Boolean, server_default=text('false'))
|
||||||
)
|
)
|
||||||
|
|
||||||
UserBlock = Table(
|
class User(BaseModel):
|
||||||
'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):
|
|
||||||
__tablename__ = 'freak_user'
|
__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)
|
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)
|
||||||
|
|
@ -118,10 +102,7 @@ class User(Base):
|
||||||
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)
|
||||||
|
|
@ -129,22 +110,18 @@ class User(Base):
|
||||||
banned_reason = Column(SmallInteger, server_default=text('0'), nullable=True)
|
banned_reason = Column(SmallInteger, server_default=text('0'), nullable=True)
|
||||||
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
|
||||||
## 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', )
|
#posts = relationship("Post", back_populates='author', )
|
||||||
upvoted_posts = relationship("Post", secondary=PostUpvote, back_populates='upvoters')
|
upvoted_posts = relationship("Post", secondary=PostUpvote, back_populates='upvoters')
|
||||||
#comments = relationship("Comment", back_populates='author')
|
#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
|
@property
|
||||||
def is_disabled(self):
|
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
|
@property
|
||||||
def is_active(self):
|
def is_active(self):
|
||||||
|
|
@ -174,7 +151,7 @@ class User(Base):
|
||||||
"""
|
"""
|
||||||
## XXX change func name?
|
## XXX change func name?
|
||||||
return dict(
|
return dict(
|
||||||
id = Snowflake(self.id).to_b32l(),
|
id = id_to_b32l(self.id),
|
||||||
username = self.username,
|
username = self.username,
|
||||||
display_name = self.display_name,
|
display_name = self.display_name,
|
||||||
age = self.age()
|
age = self.age()
|
||||||
|
|
@ -182,18 +159,15 @@ class User(Base):
|
||||||
)
|
)
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
can_create_community = deprecated('use .can_create_guild()')(can_create_guild)
|
## deprecated alias!
|
||||||
|
can_create_community = can_create_guild
|
||||||
|
|
||||||
def handle(self):
|
def handle(self):
|
||||||
return f'@{self.username}'
|
return f'@{self.username}'
|
||||||
|
|
@ -214,43 +188,10 @@ class User(Base):
|
||||||
def not_suspended(cls):
|
def not_suspended(cls):
|
||||||
return or_(User.banned_at == None, User.banned_until <= datetime.datetime.now())
|
return or_(User.banned_at == None, User.banned_until <= datetime.datetime.now())
|
||||||
|
|
||||||
@classmethod
|
class Topic(BaseModel):
|
||||||
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):
|
|
||||||
__tablename__ = 'freak_topic'
|
__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)
|
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)
|
||||||
|
|
@ -258,12 +199,8 @@ class Guild(Base):
|
||||||
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'"))
|
||||||
# true: prevent non-members from participating
|
privacy = Column(SmallInteger, server_default=text('0'))
|
||||||
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):
|
||||||
|
|
@ -273,56 +210,16 @@ class Guild(Base):
|
||||||
return f'+{self.name}'
|
return f'+{self.name}'
|
||||||
|
|
||||||
# utilities
|
# utilities
|
||||||
posts = relationship('Post', back_populates='guild')
|
posts = relationship('Post', back_populates='topic')
|
||||||
|
|
||||||
|
|
||||||
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(Base):
|
class Post(BaseModel):
|
||||||
__tablename__ = 'freak_post'
|
__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)
|
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)
|
||||||
|
|
@ -346,17 +243,16 @@ class Post(Base):
|
||||||
|
|
||||||
# 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")
|
||||||
guild = relationship("Guild", back_populates="posts", lazy='selectin')
|
topic = relationship("Topic", 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) -> Guild | User:
|
def topic_or_user(self) -> Topic | User:
|
||||||
return self.guild or self.author
|
return self.topic or self.author
|
||||||
|
|
||||||
def url(self):
|
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):
|
def generate_slug(self):
|
||||||
return slugify.slugify(self.title, max_length=64)
|
return slugify.slugify(self.title, max_length=64)
|
||||||
|
|
||||||
|
|
@ -367,7 +263,7 @@ class Post(Base):
|
||||||
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: 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:
|
||||||
if v.is_downvote:
|
if v.is_downvote:
|
||||||
return -1
|
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()
|
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 f'/report/post/{Snowflake(self.id):l}'
|
return '/report/post/' + id_to_b32l(self.id)
|
||||||
|
|
||||||
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()
|
||||||
|
|
@ -401,13 +297,12 @@ class Post(Base):
|
||||||
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(Base):
|
class Comment(BaseModel):
|
||||||
__tablename__ = 'freak_comment'
|
__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)
|
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)
|
||||||
|
|
@ -418,7 +313,6 @@ class Comment(Base):
|
||||||
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)
|
||||||
|
|
@ -426,14 +320,15 @@ class Comment(Base):
|
||||||
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] = relationship("Post", back_populates="comments", foreign_keys=[parent_post_id])
|
parent_post = relationship("Post", back_populates="comments", foreign_keys=[parent_post_id])
|
||||||
parent_comment, child_comments = parent_children('comment', parent_remote_side=Wanted('id'))
|
parent_comment = relationship("Comment", back_populates="child_comments", remote_side=[id])
|
||||||
|
child_comments = relationship("Comment", back_populates="parent_comment")
|
||||||
|
|
||||||
def url(self):
|
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:
|
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:
|
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()
|
||||||
|
|
@ -446,10 +341,8 @@ class Comment(Base):
|
||||||
def not_removed(cls):
|
def not_removed(cls):
|
||||||
return Post.removed_at == None
|
return Post.removed_at == None
|
||||||
|
|
||||||
class PostReport(Base):
|
class PostReport(BaseModel):
|
||||||
__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)
|
||||||
|
|
@ -460,7 +353,7 @@ class PostReport(Base):
|
||||||
created_ip = Column(String(64), default=get_remote_addr, nullable=False)
|
created_ip = Column(String(64), default=get_remote_addr, nullable=False)
|
||||||
|
|
||||||
author = relationship('User')
|
author = relationship('User')
|
||||||
|
|
||||||
def target(self):
|
def target(self):
|
||||||
if self.target_type == REPORT_TARGET_POST:
|
if self.target_type == REPORT_TARGET_POST:
|
||||||
return db.session.execute(select(Post).where(Post.id == self.target_id)).scalar()
|
return db.session.execute(select(Post).where(Post.id == self.target_id)).scalar()
|
||||||
|
|
@ -469,27 +362,6 @@ class PostReport(Base):
|
||||||
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 !!
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 suou import Snowflake
|
from freak.iding import id_to_b32l
|
||||||
|
|
||||||
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(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:
|
if u is None:
|
||||||
return dict(error='User not found'), 404
|
return dict(error='User not found'), 404
|
||||||
uj = dict(
|
uj = dict(
|
||||||
id = f'{Snowflake(u.id):l}',
|
id = id_to_b32l(u.id),
|
||||||
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={f'{Snowflake(id):l}': uj})
|
return dict(users={id_to_b32l(id): 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(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:
|
if p is None:
|
||||||
return dict(error='Not found'), 404
|
return dict(error='Not found'), 404
|
||||||
pj = dict(
|
pj = dict(
|
||||||
id = f'{Snowflake(p.id):l}',
|
id = id_to_b32l(p.id),
|
||||||
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={f'{Snowflake(id):l}': pj})
|
return dict(posts={id_to_b32l(id): pj})
|
||||||
|
|
@ -1,6 +1,14 @@
|
||||||
(function(){
|
(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(){
|
function attachUsernameInput(){
|
||||||
|
|
@ -132,36 +140,9 @@
|
||||||
}).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();
|
||||||
|
|
|
||||||
|
|
@ -5,26 +5,10 @@
|
||||||
box-sizing: border-box
|
box-sizing: border-box
|
||||||
|
|
||||||
\:root
|
\: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-primary: #181818
|
||||||
--light-text-alt: #444
|
--light-text-alt: #444
|
||||||
--light-border: #999
|
--light-border: #999
|
||||||
|
--light-accent: #ff7300
|
||||||
--light-success: #73af00
|
--light-success: #73af00
|
||||||
--light-error: #e04433
|
--light-error: #e04433
|
||||||
--light-canvas: #eaecee
|
--light-canvas: #eaecee
|
||||||
|
|
@ -34,21 +18,17 @@
|
||||||
--dark-text-primary: #e8e8e8
|
--dark-text-primary: #e8e8e8
|
||||||
--dark-text-alt: #c0cad3
|
--dark-text-alt: #c0cad3
|
||||||
--dark-border: #777
|
--dark-border: #777
|
||||||
|
--dark-accent: #ff7300
|
||||||
--dark-success: #93cf00
|
--dark-success: #93cf00
|
||||||
--dark-error: #e04433
|
--dark-error: #e04433
|
||||||
--dark-canvas: #0a0a0e
|
--dark-canvas: #0a0a0e
|
||||||
--dark-background: #181a21
|
--dark-background: #181a21
|
||||||
--dark-bg-sharp: #080808
|
--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-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)
|
||||||
|
--accent: var(--light-accent)
|
||||||
--success: var(--light-success)
|
--success: var(--light-success)
|
||||||
--error: var(--light-error)
|
--error: var(--light-error)
|
||||||
--canvas: var(--light-canvas)
|
--canvas: var(--light-canvas)
|
||||||
|
|
@ -60,77 +40,35 @@
|
||||||
--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)
|
||||||
|
--accent: var(--dark-accent)
|
||||||
--success: var(--dark-success)
|
--success: var(--dark-success)
|
||||||
--error: var(--dark-error)
|
--error: var(--dark-error)
|
||||||
--canvas: var(--dark-canvas)
|
--canvas: var(--dark-canvas)
|
||||||
--background: var(--dark-background)
|
--background: var(--dark-background)
|
||||||
--bg-sharp: var(--dark-bg-sharp)
|
--bg-sharp: var(--dark-bg-sharp)
|
||||||
|
|
||||||
.color-scheme-light
|
body.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)
|
||||||
|
--accent: var(--light-accent)
|
||||||
--success: var(--light-success)
|
--success: var(--light-success)
|
||||||
--error: var(--light-error)
|
--error: var(--light-error)
|
||||||
--canvas: var(--light-canvas)
|
--canvas: var(--light-canvas)
|
||||||
--background: var(--light-background)
|
--background: var(--light-background)
|
||||||
--bg-sharp: var(--light-bg-sharp)
|
--bg-sharp: var(--light-bg-sharp)
|
||||||
|
|
||||||
.color-scheme-dark
|
body.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)
|
||||||
|
--accent: var(--dark-accent)
|
||||||
--success: var(--dark-success)
|
--success: var(--dark-success)
|
||||||
--error: var(--dark-error)
|
--error: var(--dark-error)
|
||||||
--canvas: var(--dark-canvas)
|
--canvas: var(--dark-canvas)
|
||||||
--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
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,6 @@ blockquote
|
||||||
ul
|
ul
|
||||||
margin: 4px 0
|
margin: 4px 0
|
||||||
padding: 0
|
padding: 0
|
||||||
padding-inline-start: 1.5em
|
|
||||||
> li
|
> li
|
||||||
margin: 0
|
margin: 0
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -54,11 +54,6 @@ 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
|
||||||
|
|
@ -108,9 +103,6 @@ aside.card
|
||||||
padding: 12px
|
padding: 12px
|
||||||
margin: -12px -12px 0 -12px
|
margin: -12px -12px 0 -12px
|
||||||
position: relative
|
position: relative
|
||||||
a
|
|
||||||
color: inherit
|
|
||||||
text-decoration: underline
|
|
||||||
> ul
|
> ul
|
||||||
list-style: none
|
list-style: none
|
||||||
margin: 0
|
margin: 0
|
||||||
|
|
@ -140,7 +132,6 @@ 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
|
||||||
|
|
@ -148,31 +139,13 @@ ul.inline
|
||||||
margin: 0 .5em
|
margin: 0 .5em
|
||||||
&:first-child::before
|
&:first-child::before
|
||||||
content: ''
|
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
|
ul.message-options
|
||||||
color: var(--text-alt)
|
color: var(--text-alt)
|
||||||
list-style: none
|
list-style: none
|
||||||
padding: 0
|
padding: 0
|
||||||
font-size: smaller
|
font-size: smaller
|
||||||
.comment-frame &
|
margin-bottom: -4px
|
||||||
margin-bottom: -4px
|
|
||||||
|
|
||||||
.post-frame
|
.post-frame
|
||||||
margin-left: 3em
|
margin-left: 3em
|
||||||
|
|
@ -183,9 +156,6 @@ ul.message-options
|
||||||
margin-left: 0
|
margin-left: 0
|
||||||
margin-right: 3em
|
margin-right: 3em
|
||||||
|
|
||||||
.message-options
|
|
||||||
margin-bottom: 1em
|
|
||||||
|
|
||||||
.message-stats
|
.message-stats
|
||||||
position: absolute
|
position: absolute
|
||||||
left: -3em
|
left: -3em
|
||||||
|
|
@ -302,7 +272,7 @@ button, [type="submit"], [type="reset"], [type="button"]
|
||||||
|
|
||||||
&.primary
|
&.primary
|
||||||
background-color: var(--accent)
|
background-color: var(--accent)
|
||||||
color: var(--background)
|
color: var(--bg-main)
|
||||||
|
|
||||||
&[disabled]
|
&[disabled]
|
||||||
opacity: .5
|
opacity: .5
|
||||||
|
|
@ -328,27 +298,5 @@ 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)
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,19 +6,11 @@
|
||||||
.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
|
||||||
|
|
||||||
|
|
@ -26,11 +18,4 @@
|
||||||
display: inline-block
|
display: inline-block
|
||||||
|
|
||||||
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
|
|
||||||
|
|
@ -6,13 +6,11 @@
|
||||||
<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">
|
||||||
{% for private_style in private_styles %}
|
<style>.done{opacity:.5}</style>
|
||||||
<link rel="stylesheet" href="{{ private_style }}" />
|
|
||||||
{% endfor %}
|
|
||||||
</head>
|
</head>
|
||||||
<body class="admin">
|
<body>
|
||||||
<div class="header">
|
<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>
|
||||||
<div class="content">
|
<div class="content">
|
||||||
{% for message in get_flashed_messages() %}
|
{% for message in get_flashed_messages() %}
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,9 @@
|
||||||
{% extends "admin/admin_base.html" %}
|
{% extends "admin/admin_base.html" %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<ul class="grid">
|
<ul>
|
||||||
<li>
|
<li>
|
||||||
<h2><a href="{{ url_for('admin.reports') }}">Reports</a></h2>
|
<a href="{{ url_for('admin.reports') }}">Reports</a>
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<h2><a href="{{ url_for('admin.strikes') }}">Strikes</a></h2>
|
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
{% 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>
|
||||||
|
|
@ -15,20 +14,10 @@
|
||||||
{% 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 %}
|
||||||
|
|
|
||||||
|
|
@ -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 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 %}
|
||||||
|
|
@ -15,8 +14,6 @@
|
||||||
This service is age-restricted; do not access if underage.
|
This service is age-restricted; do not access if underage.
|
||||||
More info: https://{{ domain_name }}/terms
|
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() }}">
|
<meta name="csrf_token" content="{{ csrf_token() }}">
|
||||||
<link rel="stylesheet" href="{{ url_for_css('style') }}" />
|
<link rel="stylesheet" href="{{ url_for_css('style') }}" />
|
||||||
{# psa: icons url MUST be supplied by .env via PRIVATE_ASSETS= #}
|
{# psa: icons url MUST be supplied by .env via PRIVATE_ASSETS= #}
|
||||||
|
|
@ -26,7 +23,7 @@
|
||||||
<link rel="icon" href="/favicon.ico" />
|
<link rel="icon" href="/favicon.ico" />
|
||||||
<script src="{{ jquery_url }}"></script>
|
<script src="{{ jquery_url }}"></script>
|
||||||
</head>
|
</head>
|
||||||
<body {% if current_user.color_theme %} class="{{ theme_classes(current_user.color_theme) }}"{% endif %}>
|
<body>
|
||||||
<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">
|
||||||
|
|
@ -46,28 +43,30 @@
|
||||||
{% if g.no_user %}
|
{% if g.no_user %}
|
||||||
<!-- no user -->
|
<!-- no user -->
|
||||||
{% elif current_user.is_authenticated %}
|
{% elif current_user.is_authenticated %}
|
||||||
<li>
|
<li><a href="/create" title="Create a post">
|
||||||
<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>
|
||||||
<span class="nomobile">New post</span>
|
</a></li><li><a href="{{ current_user.url() }}"
|
||||||
</a>
|
title="@{{ current_user.username }}'s profile">
|
||||||
</li>
|
<i class="icon icon-profile"></i>
|
||||||
<li><a href="{{ current_user.url() }}" title="{{ current_user.handle() }}'s profile">{{ icon('profile')}}<span class="a11y">profile</span></a>
|
<span class="a11y">profile</span>
|
||||||
|
</a></li><li>
|
||||||
<div class="header-username">
|
<div class="header-username">
|
||||||
<strong class="header-username-name">{{ current_user.handle() }}</strong>
|
<strong class="header-username-name">@{{ current_user.username }}</strong>
|
||||||
<span class="header-username-karma">{{ icon('karma') }} {{ current_user.karma }} karma</span>
|
<span class="header-username-karma"><i class="icon icon-karma"></i> {{ current_user.karma }} karma</span>
|
||||||
</div></li>
|
</div>
|
||||||
|
</li>
|
||||||
<li><a href="/logout" title="Log out">
|
<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>
|
</a></li>
|
||||||
{% else %}
|
{% else %}
|
||||||
<li><a href="/login" title="Log in">
|
<li><a href="/login" title="Log in">
|
||||||
{{ icon('logout') }}<span class="a11y">log in</span>
|
<i class="icon icon-logout"></i>
|
||||||
</a></li>
|
<span class="a11y">log in</span>
|
||||||
<li><a href="/register" title="Register">
|
</a></li><li><a href="/register" title="Register">
|
||||||
{{ icon('join') }}<span class="a11y">register</span>
|
<i class="icon icon-join"></i>
|
||||||
</a></li>
|
<span class="a11y">register</span>
|
||||||
|
</a></li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</ul>
|
</ul>
|
||||||
</div><!-- .metanav -->
|
</div><!-- .metanav -->
|
||||||
|
|
@ -103,9 +102,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.classList.add('night');
|
document.body.style.setProperty('--accent', '#1871d8');
|
||||||
} else {
|
} else {
|
||||||
document.body.classList.remove('night');
|
document.body.style.removeProperty('--accent');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
changeAccentColorTime();
|
changeAccentColorTime();
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% from "macros/feed.html" import feed_post, stop_scrolling, no_more_scrolling with context %}
|
{% 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/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 = 'For you' if feed_type == 'foryou' and not feed_title %}
|
||||||
{% set feed_title = 'Explore' if feed_type == 'explore' and not feed_title #}
|
{% set feed_title = 'Explore' if feed_type == 'explore' and not feed_title #}
|
||||||
|
|
@ -17,11 +16,13 @@
|
||||||
|
|
||||||
{% block nav %}
|
{% block nav %}
|
||||||
{% if top_communities %}
|
{% if top_communities %}
|
||||||
{{ nav_top_communities(top_communities) }}
|
{% from "macros/nav.html" import nav_top_communities with context %}
|
||||||
|
{{ nav_top_communities(top_communities) }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if feed_type == 'guild' %}
|
{% if feed_type == 'topic' %}
|
||||||
{{ nav_guild(guild) }}
|
{% from "macros/nav.html" import nav_topic with context %}
|
||||||
|
{{ nav_topic(topic) }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<aside class="card">
|
<aside class="card">
|
||||||
|
|
|
||||||
|
|
@ -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.guild %}
|
{% elif p.topic %}
|
||||||
on <a href="{{ p.guild.url() }}">{{ p.guild.handle() }}</a>
|
on <a href="{{ p.topic.url() }}">+{{ p.topic.name }}</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
on their user page
|
on their user page
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
|
||||||
|
|
@ -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.guild %}
|
{% if p.topic %}
|
||||||
on <a href="{{ p.guild.url() }}">{{ p.guild.handle() }}</a>
|
on <a href="{{ p.topic.url() }}">+{{ p.topic.name }}</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
on their user page
|
on their user page
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
|
|
||||||
{% macro nav_guild(gu) %}
|
{% macro nav_topic(topic) %}
|
||||||
<aside class="card">
|
<aside class="card">
|
||||||
<h3>About <a href="{{ gu.url() }}">{{ gu.handle() }}</a></h3>
|
<h3>About {{ topic.handle() }}</h3>
|
||||||
<ul>
|
<ul>
|
||||||
<li><i class="icon icon-info" style="font-size:inherit"></i> {{ gu.description }}</li>
|
<li><i class="icon icon-info" style="font-size:inherit"></i> {{ topic.description }}</li>
|
||||||
<li>
|
<li>
|
||||||
<strong>{{ gu.posts | count }}</strong> posts -
|
<strong>{{ topic.posts | count }}</strong> posts -
|
||||||
<strong>-</strong> subscribers
|
<strong>-</strong> subscribers
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
@ -14,11 +14,9 @@
|
||||||
|
|
||||||
{% macro nav_user(user) %}
|
{% macro nav_user(user) %}
|
||||||
<aside class="card">
|
<aside class="card">
|
||||||
<h3>About <a href="{{ user.url() }}">{{ user.display_name or user.handle() }}</a></h3>
|
<h3>About {{ user.handle() }}</h3>
|
||||||
<ul>
|
<ul>
|
||||||
{% if user.biography %}
|
<li>{# user.biography #}</li>
|
||||||
<li>{{ icon('info') }} {{ user.biography }}</li>
|
|
||||||
{% endif %}
|
|
||||||
</ul>
|
</ul>
|
||||||
</aside>
|
</aside>
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
|
||||||
|
|
@ -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 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>
|
</div>
|
||||||
{% if not current_user.is_anonymous %}
|
{% if not current_user.is_anonymous %}
|
||||||
<div>
|
<div>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -189,6 +189,4 @@ The updated ruleset is always available at [https://ndspir.it/rules](https://nds
|
||||||
In case of conflicts or discrepancies between translations, the English version takes precedence.
|
In case of conflicts or discrepancies between translations, the English version takes precedence.
|
||||||
|
|
||||||
The entire text of our General Regulation is free for everyone to use, as long as the text and its core concepts are not altered in a significant way. We encourage its adoption in order to make rules more clear, respecting them more mindless, and moderation easier.
|
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 %}
|
|
||||||
|
|
@ -3,22 +3,13 @@
|
||||||
{% from "macros/feed.html" import single_comment, feed_upvote, comment_count with context %}
|
{% from "macros/feed.html" import single_comment, feed_upvote, comment_count with context %}
|
||||||
{% from "macros/create.html" import comment_area with context %}
|
{% from "macros/create.html" import comment_area with context %}
|
||||||
{% from "macros/icon.html" import icon, callout with context %}
|
{% from "macros/icon.html" import icon, callout with context %}
|
||||||
{% from "macros/nav.html" import nav_guild, nav_user with context %}
|
|
||||||
|
|
||||||
{% block title %}
|
{% block title %}{{ title_tag(p.title + '; from ' + p.topic_or_user().handle()) }}{% endblock %}
|
||||||
{{ 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 nav %}
|
{% block nav %}
|
||||||
{% if p.guild %}
|
{% if p.topic %}
|
||||||
{{ nav_guild(p.guild) }}
|
{% from "macros/nav.html" import nav_topic with context %}
|
||||||
{% elif p.author %}
|
{{ nav_topic(p.topic) }}
|
||||||
{{ nav_user(p.author) }}
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
|
@ -29,15 +20,12 @@
|
||||||
<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.guild %}
|
{% if p.topic %}
|
||||||
on <a href="{{ p.guild.url() }}">+{{ p.guild.name }}</a>
|
on <a href="{{ p.topic.url() }}">+{{ p.topic.name }}</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
on their user page
|
on their user page
|
||||||
{% endif %}
|
{% endif %}
|
||||||
- <time datetime="{{ p.created_at.isoformat('T') }}">{{ p.created_at.strftime('%B %-d, %Y at %H:%M') }}</time>
|
- <time datetime="{{ p.created_at.isoformat('T') }}">{{ p.created_at.strftime('%B %-d, %Y at %H:%M') }}</time>
|
||||||
{% if p.privacy == 1 %}
|
|
||||||
- {{ icon('link_post') }} Unlisted
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
{% if current_user.is_administrator and p.report_count() %}
|
{% if current_user.is_administrator and p.report_count() %}
|
||||||
{% call callout() %}
|
{% call callout() %}
|
||||||
|
|
@ -57,15 +45,14 @@
|
||||||
{{ feed_upvote(p.id, p.upvotes(), p.upvoted_by(current_user)) }}
|
{{ feed_upvote(p.id, p.upvotes(), p.upvoted_by(current_user)) }}
|
||||||
{{ comment_count(p.comments | count) }}
|
{{ 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>
|
|
||||||
{% else %}
|
|
||||||
<li><a href="{{ p.report_url() }}"><i class="icon icon-report"></i> Report</a></li>
|
|
||||||
{% endif %}
|
|
||||||
</ul>
|
|
||||||
</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>
|
||||||
|
{% else %}
|
||||||
|
<li><a href="{{ p.report_url() }}"><i class="icon icon-report"></i> Report</a></li>
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
{{ comment_area(p.url()) }}
|
{{ comment_area(p.url()) }}
|
||||||
<div class="comment-section">
|
<div class="comment-section">
|
||||||
<ul>
|
<ul>
|
||||||
|
|
|
||||||
|
|
@ -2,24 +2,11 @@
|
||||||
{% from "macros/feed.html" import feed_post, stop_scrolling, no_more_scrolling with context %}
|
{% 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/title.html" import title_tag with context %}
|
||||||
{% from "macros/icon.html" import icon, callout 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 title %}{{ title_tag(user.handle() + '’s content') }}{% endblock %}
|
||||||
|
|
||||||
{% block heading %}
|
{% block heading %}
|
||||||
<h2>{{ user.handle() }}</h2>
|
<h2>{{ user.handle() }}</h2>
|
||||||
<ul class="inline">
|
<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>
|
||||||
<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 %}
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% 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
|
import sys
|
||||||
from flask import Blueprint, render_template, __version__ as flask_version
|
from flask import Blueprint, render_template, __version__ as flask_version
|
||||||
from sqlalchemy import __version__ as sa_version
|
from sqlalchemy import __version__ as sa_version
|
||||||
|
from .. import __version__ as app_version
|
||||||
|
|
||||||
bp = Blueprint('about', __name__)
|
bp = Blueprint('about', __name__)
|
||||||
|
|
||||||
|
|
@ -10,7 +11,8 @@ def about():
|
||||||
return render_template('about.html',
|
return render_template('about.html',
|
||||||
flask_version=flask_version,
|
flask_version=flask_version,
|
||||||
sa_version=sa_version,
|
sa_version=sa_version,
|
||||||
python_version=sys.version.split()[0]
|
python_version=sys.version.split()[0],
|
||||||
|
app_version=app_version
|
||||||
)
|
)
|
||||||
|
|
||||||
@bp.route('/terms/')
|
@bp.route('/terms/')
|
||||||
|
|
|
||||||
|
|
@ -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, abort, render_template, request, redirect, flash
|
from flask import Blueprint, render_template, request, redirect, flash
|
||||||
from flask_login import login_required, login_user, logout_user, current_user
|
from flask_login import 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,30 +101,3 @@ 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')
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,13 +3,11 @@
|
||||||
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 insert, select, update
|
from sqlalchemy import select, update
|
||||||
from suou import additem, not_implemented
|
|
||||||
|
|
||||||
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__)
|
bp = Blueprint('admin', __name__)
|
||||||
|
|
||||||
|
|
@ -24,96 +22,36 @@ 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 = reason_code
|
target.removed_reason = source.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 = reason_code
|
target.removed_reason = source.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 = {
|
||||||
@additem(REPORT_ACTIONS, '4')
|
'1': accept_report,
|
||||||
@not_implemented()
|
'0': reject_report,
|
||||||
def escalate_report(target, source: PostReport):
|
'2': withhold_report
|
||||||
...
|
}
|
||||||
|
|
||||||
## END report actions
|
|
||||||
|
|
||||||
@bp.route('/admin/')
|
@bp.route('/admin/')
|
||||||
@admin_required
|
@admin_required
|
||||||
|
|
@ -139,10 +77,3 @@ 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)
|
|
||||||
|
|
|
||||||
|
|
@ -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, select
|
from sqlalchemy import insert
|
||||||
from ..models import User, db, Guild, Post
|
from ..models import User, db, Topic, 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:
|
||||||
gname = request.form['to']
|
topic_name = request.form['to']
|
||||||
if gname:
|
if topic_name:
|
||||||
guild: Guild | None = db.session.execute(select(Guild).where(Guild.name == gname)).scalar()
|
topic: Topic | None = db.session.execute(db.select(Topic).where(Topic.name == topic_name)).scalar()
|
||||||
if guild is None:
|
if topic is None:
|
||||||
flash(f'Guild +{gname} not found or inaccessible, posting to your user page instead')
|
flash(f'Topic +{topic_name} not found, posting to your user page instead')
|
||||||
else:
|
else:
|
||||||
guild = None
|
topic = 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 = guild.id if guild else None,
|
topic_id = topic.id if topic 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 {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))
|
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,18 +55,15 @@ def createguild():
|
||||||
|
|
||||||
c_name = request.form['name']
|
c_name = request.form['name']
|
||||||
try:
|
try:
|
||||||
new_guild = db.session.execute(insert(Guild).values(
|
c_id = db.session.execute(db.insert(Topic).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(Guild)).scalar()
|
).returning(Topic.id)).fetchone()
|
||||||
|
|
||||||
if new_guild is None:
|
|
||||||
raise RuntimeError('no returning')
|
|
||||||
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
return redirect(new_guild.url())
|
return redirect(url_for('frontpage.topic_feed', name=c_name))
|
||||||
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.')
|
||||||
|
|
|
||||||
|
|
@ -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
|
from flask_login import current_user, login_required
|
||||||
from sqlalchemy import insert, select
|
from sqlalchemy import 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, Guild, db, User, Post
|
from ..models import Comment, db, User, Post, Topic
|
||||||
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 = int(Snowflake.from_b32l(username))
|
userid = id_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 == 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,
|
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(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:
|
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 slug != post.slug:
|
if post.slug and not slug:
|
||||||
return redirect(post.url()), 302
|
return redirect(url_for('detail.user_post_detail_slug', username=username, id=id, slug=post.slug)), 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('/+<gname>/comments/<b32l:id>/', methods=['GET', 'POST'])
|
@bp.route('/+<topicname>/comments/<b32l:id>/', methods=['GET', 'POST'])
|
||||||
@bp.route('/+<gname>/comments/<b32l:id>/<slug:slug>', methods=['GET', 'POST'])
|
@bp.route('/+<topicname>/comments/<b32l:id>/<slug:slug>', methods=['GET', 'POST'])
|
||||||
def guild_post_detail(gname, id, slug=''):
|
def topic_post_detail(topicname, id, slug=''):
|
||||||
post: Post | None = db.session.execute(select(Post).join(Guild).where(Post.id == id, Guild.name == gname)).scalar()
|
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):
|
if post is None or (post.is_removed and post.author != current_user):
|
||||||
abort(404)
|
abort(404)
|
||||||
|
|
||||||
if post.slug and slug != post.slug:
|
if post.slug and not slug:
|
||||||
return redirect(post.url()), 302
|
return redirect(url_for('detail.topic_post_detail_slug', topicname=topicname, id=id, slug=post.slug)), 302
|
||||||
|
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
single_post_post_hook(post)
|
single_post_post_hook(post)
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@
|
||||||
import datetime
|
import datetime
|
||||||
from flask import Blueprint, abort, flash, redirect, render_template, request
|
from flask import Blueprint, abort, flash, redirect, render_template, request
|
||||||
from flask_login import current_user, login_required
|
from flask_login import current_user, login_required
|
||||||
from sqlalchemy import select
|
|
||||||
|
|
||||||
from ..models import Post, db
|
from ..models import Post, db
|
||||||
|
|
||||||
|
|
@ -14,7 +13,7 @@ bp = Blueprint('edit', __name__)
|
||||||
@bp.route('/edit/post/<b32l:id>', methods=['GET', 'POST'])
|
@bp.route('/edit/post/<b32l:id>', methods=['GET', 'POST'])
|
||||||
@login_required
|
@login_required
|
||||||
def edit_post(id):
|
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:
|
if p is None:
|
||||||
abort(404)
|
abort(404)
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,8 @@
|
||||||
|
|
||||||
from flask import Blueprint, render_template, redirect, abort, request
|
from flask import Blueprint, render_template, redirect, abort, request
|
||||||
from flask_login import current_user
|
from flask_login import current_user
|
||||||
from sqlalchemy import select
|
|
||||||
|
|
||||||
from ..search import SearchQuery
|
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
|
from ..algorithms import public_timeline, top_guilds_query, topic_timeline
|
||||||
|
|
||||||
bp = Blueprint('frontpage', __name__)
|
bp = Blueprint('frontpage', __name__)
|
||||||
|
|
@ -32,19 +30,19 @@ def explore():
|
||||||
|
|
||||||
|
|
||||||
@bp.route('/+<name>/')
|
@bp.route('/+<name>/')
|
||||||
def guild_feed(name):
|
def topic_feed(name):
|
||||||
guild: Guild | None = db.session.execute(select(Guild).where(Guild.name == name)).scalar()
|
topic: Topic = db.session.execute(db.select(Topic).where(Topic.name == name)).scalar()
|
||||||
|
|
||||||
if guild is None:
|
if topic 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'{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>/')
|
@bp.route('/r/<name>/')
|
||||||
def guild_feed_r(name):
|
def topic_feed_r(name):
|
||||||
return redirect('/+' + name + '/'), 302
|
return redirect('/+' + name + '/'), 302
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ dependencies = [
|
||||||
"PsycoPG2-binary",
|
"PsycoPG2-binary",
|
||||||
"libsass",
|
"libsass",
|
||||||
"setuptools>=78.1.0",
|
"setuptools>=78.1.0",
|
||||||
"sakuragasaki46-suou>=0.3.3"
|
"sakuragasaki46-suou>=0.2.3"
|
||||||
]
|
]
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
classifiers = [
|
classifiers = [
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue