Compare commits

..

No commits in common. "c1c005cc4e94036ba5880d274b5f511d041a4bfa" and "22524c5920a26a5132d4b7bab1bfd5dbea78d05b" have entirely different histories.

43 changed files with 289 additions and 1081 deletions

View file

@ -1,18 +1,5 @@
# Changelog
## 0.4.0
- Added dependency to [SUOU](https://github.com/sakuragasaki46/suou)
- Added user strikes, memberships and user blocks
- Added ✨color themes✨
- Users can now set their display name and biography in `/settings`
## 0.3.3
- Fixed bugs in templates introduced in 0.3.2
- Improved karma management
- Fixed og: meta tags missing
## 0.3.2
- Fixed administrator users not being able to create +guilds

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -4,40 +4,22 @@ import argparse
import os
import subprocess
from sqlalchemy import create_engine, select
from sqlalchemy.orm import Session
from . import __version__ as version, app
from .models import User, db
from sqlalchemy import create_engine
from . import __version__ as version
from .models import db
def make_parser():
parser = argparse.ArgumentParser()
parser.add_argument('--version', '-v', action='version', version=version)
parser.add_argument('--upgrade', '-U', action='store_true', help='create or upgrade schema')
parser.add_argument('--flush', '-H', action='store_true', help='recompute karma for all users')
return parser
def main():
args = make_parser().parse_args()
engine = create_engine(os.getenv('DATABASE_URL'))
if args.upgrade:
ret_code = subprocess.Popen(['alembic', 'upgrade', 'head']).wait()
if ret_code != 0:
print(f'Schema upgrade failed (code: {ret_code})')
exit(ret_code)
# if the alembic/versions folder is empty
db.metadata.create_all(engine)
db.metadata.create_all(create_engine(os.getenv('DATABASE_URL')))
subprocess.Popen(['alembic', 'upgrade', 'head']).wait()
print('Schema upgraded!')
if args.flush:
cnt = 0
with app.app_context():
for u in db.session.execute(select(User)).scalars():
u.recompute_karma()
cnt += 1
db.session.add(u)
db.session.commit()
print(f'Recomputed karma of {cnt} users')
print(f'Visit <https://{os.getenv("DOMAIN_NAME")}>')

View file

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

View file

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

View file

@ -1,36 +1,78 @@
import markdown
import re, markdown
from markdown.inlinepatterns import InlineProcessor, SimpleTagInlineProcessor
import xml.etree.ElementTree as etree
from markupsafe import Markup
from suou import Snowflake
from suou.markdown import StrikethroughExtension, SpoilerExtension, PingExtension
from . import app
from .iding import id_to_b32l
#### MARKDOWN EXTENSIONS ####
class StrikethroughExtension(markdown.extensions.Extension):
def extendMarkdown(self, md: markdown.Markdown, md_globals=None):
postprocessor = StrikethroughPostprocessor(md)
md.postprocessors.register(postprocessor, 'strikethrough', 0)
class StrikethroughPostprocessor(markdown.postprocessors.Postprocessor):
pattern = re.compile(r"~~(((?!~~).)+)~~", re.DOTALL)
def run(self, html):
return re.sub(self.pattern, self.convert, html)
def convert(self, match):
return '<del>' + match.group(1) + '</del>'
### XXX it currently only detects spoilers that are not at the beginning of the line. To be fixed.
class SpoilerExtension(markdown.extensions.Extension):
def extendMarkdown(self, md: markdown.Markdown, md_globals=None):
md.inlinePatterns.register(SimpleTagInlineProcessor(r'()>!(.*?)!<', 'span class="spoiler"'), 'spoiler', 14)
@classmethod
def patch_blockquote_processor(cls):
"""Patch BlockquoteProcessor to make Spoiler prevail over blockquotes."""
from markdown.blockprocessors import BlockQuoteProcessor
BlockQuoteProcessor.RE = re.compile(r'(^|\n)[ ]{0,3}>(?!!)[ ]?(.*)')
# make spoilers prevail over blockquotes
SpoilerExtension.patch_blockquote_processor()
class MentionPattern(InlineProcessor):
def __init__(self, regex, url_prefix: str):
super().__init__(regex)
self.url_prefix = url_prefix
def handleMatch(self, m, data):
el = etree.Element('a')
el.attrib['href'] = self.url_prefix + m.group(1)
el.text = m.group(0)
return el, m.start(0), m.end(0)
class PingExtension(markdown.extensions.Extension):
def extendMarkdown(self, md: markdown.Markdown, md_globals=None):
md.inlinePatterns.register(MentionPattern(r'@([a-zA-Z0-9_-]{2,32})', '/@'), 'ping_mention', 14)
md.inlinePatterns.register(MentionPattern(r'\+([a-zA-Z0-9_-]{2,32})', '/+'), 'ping_mention', 14)
@app.template_filter()
def to_markdown(text, toc = False):
extensions = [
'tables', 'footnotes', 'fenced_code', 'sane_lists',
StrikethroughExtension(), SpoilerExtension(),
PingExtension({'@': '/@', '+': '/+'})
## XXX untested
PingExtension()
]
if toc:
extensions.append('toc')
return Markup(markdown.Markdown(extensions=extensions).convert(text))
app.template_filter('markdown')(to_markdown)
@app.template_filter()
def to_b32l(n):
return Snowflake(n).to_b32l()
return id_to_b32l(n)
app.template_filter('b32l')(to_b32l)
@app.template_filter()
def append(text, l: list):
def append(text, l):
l.append(text)
return None

View file

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

View file

@ -4,20 +4,18 @@ from __future__ import annotations
from collections import namedtuple
import datetime
from functools import partial
from functools import lru_cache
from operator import or_
from threading import Lock
from sqlalchemy import Column, Integer, String, ForeignKey, UniqueConstraint, text, \
from sqlalchemy import Column, String, ForeignKey, and_, text, \
CheckConstraint, Date, DateTime, Boolean, func, BigInteger, \
SmallInteger, select, update, Table
from sqlalchemy.orm import Relationship, relationship
SmallInteger, select, insert, update, create_engine, Table
from sqlalchemy.orm import Relationship, declarative_base, relationship
from flask_sqlalchemy import SQLAlchemy
from flask_login import AnonymousUserMixin
from suou import SiqType, Snowflake, Wanted, deprecated, not_implemented
from suou.sqlalchemy import create_session, declarative_base, id_column, parent_children, snowflake_column
from werkzeug.security import check_password_hash
from freak import app_config
import os
from .iding import new_id, id_to_b32l
from .utils import age_and_days, get_remote_addr, timed_cache
@ -27,27 +25,23 @@ USER_ACTIVE = 0
USER_INACTIVE = 1
USER_BANNED = 2
ReportReason = namedtuple('ReportReason', 'num_code code description extra', defaults=dict(extra=None))
ReportReason = namedtuple('ReportReason', 'num_code code description')
post_report_reasons = [
## emergency
ReportReason(110, 'hate_speech', 'Extreme hate speech / terrorism'),
ReportReason(121, 'csam', 'Child abuse or endangerment', extra=dict(suspend=True)),
ReportReason(121, 'csam', 'Child abuse or endangerment'),
ReportReason(142, 'revenge_sxm', 'Revenge porn'),
ReportReason(122, 'black_market', 'Sale or promotion of regulated goods (e.g. firearms)'),
## urgent
ReportReason(171, 'xxx', 'Pornography'),
ReportReason(111, 'tasteless', 'Extreme violence / gore'),
ReportReason(180, 'impersonation', 'Impersonation'),
ReportReason(141, 'doxing', 'Diffusion of PII (personally identifiable information)'),
## less urgent
ReportReason(123, 'copyviol', 'This is my creation and someone else is using it without my permission/license'),
ReportReason(140, 'bullying', 'Harassment, bullying, or suicide incitement'),
ReportReason(112, 'lgbt_hate', 'Hate speech against LGBTQ+ or women'),
ReportReason(150, 'security_exploit', 'Dangerous security exploit or violation'),
ReportReason(190, 'false_information', 'False or deceiving information'),
ReportReason(123, 'copyviol', 'This is my creation and someone else is using it without my permission/license'),
## minor (unironically)
ReportReason(210, 'underage', 'Presence in violation of age limits (i.e. under 13, or minor in adult spaces)', extra=dict(suspend=True))
ReportReason(210, 'underage', 'Presence in violation of age limits (i.e. under 13, or minor in adult spaces)')
]
REPORT_REASON_STRINGS = { **{x.num_code: x.description for x in post_report_reasons}, **{x.code: x.description for x in post_report_reasons} }
@ -64,18 +58,20 @@ REPORT_UPDATE_ON_HOLD = 3
## END constants and enums
Base = declarative_base(app_config.domain_name, app_config.secret_key,
snowflake_epoch=1577833200)
Base = declarative_base()
db = SQLAlchemy(model_class=Base)
CSI = create_session_interactively = partial(create_session, app_config.database_url)
def create_session_interactively():
'''Create a session for querying the database in Python REPL.'''
engine = create_engine(os.getenv('DATABASE_URL'))
return db.Session(bind = engine)
CSI = create_session_interactively
# the BaseModel() class will be removed in 0.5
from .iding import new_id
@deprecated('id_column() and explicit id column are better. Will be removed in 0.5')
## TODO replace with suou.declarative_base() - upcoming 0.4
class BaseModel(Base):
__abstract__ = True
id = Column(BigInteger, primary_key=True, default=new_id)
## Many-to-many relationship keys for some reasons have to go
@ -90,22 +86,10 @@ PostUpvote = Table(
Column('is_downvote', Boolean, server_default=text('false'))
)
UserBlock = Table(
'freak_user_block',
Base.metadata,
Column('actor_id', BigInteger, ForeignKey('freak_user.id'), primary_key=True),
Column('target_id', BigInteger, ForeignKey('freak_user.id'), primary_key=True)
)
class User(Base):
class User(BaseModel):
__tablename__ = 'freak_user'
__table_args__ = (
## XXX this constraint (and the other three at Post, Guild and Comment) cannot be removed!!
UniqueConstraint('id', name='user_id_uniq'),
)
id = snowflake_column()
id = Column(BigInteger, primary_key=True, default=new_id, unique=True)
username = Column(String(32), CheckConstraint(text("username = lower(username) and username ~ '^[a-z0-9_-]+$'"), name="user_username_valid"), unique=True, nullable=False)
display_name = Column(String(64), nullable=False)
@ -118,10 +102,7 @@ class User(Base):
is_disabled_by_user = Column(Boolean, server_default=text('false'), nullable=False)
karma = Column(BigInteger, server_default=text('0'), nullable=False)
legacy_id = Column(BigInteger, nullable=True)
pronouns = Column(Integer, server_default=text('0'), nullable=False)
biography = Column(String(1024), nullable=True)
color_theme = Column(SmallInteger, nullable=False, server_default=text('0'))
# TODO add pronouns and biography (upcoming 0.4)
# moderation
banned_at = Column(DateTime, nullable=True)
@ -130,21 +111,17 @@ class User(Base):
banned_until = Column(DateTime, nullable=True)
banned_message = Column(String(256), nullable=True)
# invites
is_approved = Column(Boolean, server_default=text('false'), nullable=False)
invited_by_id = Column(BigInteger, ForeignKey('freak_user.id', name='user_inviter_id'), nullable=True)
# utilities
## XXX posts and comments relationships are temporarily disabled because they make
## SQLAlchemy fail initialization of models — bricking the app.
## Posts are queried manually anyway
#posts = relationship("Post", back_populates='author', )
upvoted_posts = relationship("Post", secondary=PostUpvote, back_populates='upvoters')
#comments = relationship("Comment", back_populates='author')
## XXX posts and comments relationships are temporarily disabled because they make
## SQLAlchemy fail initialization of models — bricking the app.
## Posts are queried manually anyway
@property
def is_disabled(self):
return (self.banned_at is not None and (self.banned_until is None or self.banned_until <= datetime.datetime.now())) or self.is_disabled_by_user
return self.banned_at is not None or self.is_disabled_by_user
@property
def is_active(self):
@ -174,7 +151,7 @@ class User(Base):
"""
## XXX change func name?
return dict(
id = Snowflake(self.id).to_b32l(),
id = id_to_b32l(self.id),
username = self.username,
display_name = self.display_name,
age = self.age()
@ -182,18 +159,15 @@ class User(Base):
)
def reward(self, points=1):
"""
Manipulate a user's karma on the fly
"""
with Lock():
db.session.execute(update(User).where(User.id == self.id).values(karma = self.karma + points))
db.session.commit()
def can_create_guild(self):
## TODO make guild creation requirements configurable
return self.karma > 15 or self.is_administrator
can_create_community = deprecated('use .can_create_guild()')(can_create_guild)
## deprecated alias!
can_create_community = can_create_guild
def handle(self):
return f'@{self.username}'
@ -214,43 +188,10 @@ class User(Base):
def not_suspended(cls):
return or_(User.banned_at == None, User.banned_until <= datetime.datetime.now())
@classmethod
def has_not_blocked(cls, actor, target):
"""
Filter out a content if the author has blocked current user.
XXX untested.
"""
# TODO add recognition
actor_id = actor
target_id = target
qq= ~select(UserBlock).where(UserBlock.c.actor_id == actor_id, UserBlock.c.target_id == target_id).exists()
print(qq)
return qq
def recompute_karma(self):
c = 0
c += db.session.execute(select(func.count('*')).select_from(Post).where(Post.author == self)).scalar()
c += db.session.execute(select(func.count('*')).select_from(PostUpvote).join(Post).where(Post.author == self, PostUpvote.c.is_downvote == False)).scalar()
c -= db.session.execute(select(func.count('*')).select_from(PostUpvote).join(Post).where(Post.author == self, PostUpvote.c.is_downvote == True)).scalar()
self.karma = c
@timed_cache(60)
def strike_count(self) -> int:
return db.session.execute(select(func.count('*')).select_from(UserStrike).where(UserStrike.user_id == self.id)).scalar()
## END User
class Guild(Base):
class Topic(BaseModel):
__tablename__ = 'freak_topic'
__table_args__ = (
UniqueConstraint('id', name='topic_id_uniq'),
)
id = snowflake_column()
id = Column(BigInteger, primary_key=True, default=new_id, unique=True)
name = Column(String(32), CheckConstraint(text("name = lower(name) AND name ~ '^[a-z0-9_-]+$'"), name='topic_name_valid'), unique=True, nullable=False)
display_name = Column(String(64), nullable=False)
@ -258,12 +199,8 @@ class Guild(Base):
created_at = Column(DateTime, server_default=func.current_timestamp(), index=True, nullable=False)
owner_id = Column(BigInteger, ForeignKey('freak_user.id', name='topic_owner_id'), nullable=True)
language = Column(String(16), server_default=text("'en-US'"))
# true: prevent non-members from participating
is_restricted = Column(Boolean, server_default=text('false'), nullable=False)
# false: make the guild invite-only
is_public = Column(Boolean, server_default=text('true'), nullable=False)
privacy = Column(SmallInteger, server_default=text('0'))
# MUST NOT be filled in on post-0.2 instances
legacy_id = Column(BigInteger, nullable=True)
def url(self):
@ -273,56 +210,16 @@ class Guild(Base):
return f'+{self.name}'
# utilities
posts = relationship('Post', back_populates='guild')
Topic = deprecated('renamed to Guild')(Guild)
## END Guild
class Member(Base):
"""
User-Guild relationship. NEW in 0.4.0.
"""
__tablename__ = 'freak_member'
__table_args__ = (
UniqueConstraint('user_id', 'guild_id', name='member_user_topic'),
)
## Newer tables use SIQ. Older tables will gradually transition to SIQ as well.
id = id_column(SiqType.MANYTOMANY)
user_id = Column(BigInteger, ForeignKey('freak_user.id'))
guild_id = Column(BigInteger, ForeignKey('freak_topic.id'))
is_approved = Column(Boolean, server_default=text('false'), nullable=False)
is_subscribed = Column(Boolean, server_default=text('false'), nullable=False)
is_moderator = Column(Boolean, server_default=text('false'), nullable=False)
# moderation
banned_at = Column(DateTime, nullable=True)
banned_by_id = Column(BigInteger, ForeignKey('freak_user.id', name='user_banner_id'), nullable=True)
banned_reason = Column(SmallInteger, server_default=text('0'), nullable=True)
banned_until = Column(DateTime, nullable=True)
banned_message = Column(String(256), nullable=True)
user = relationship(User, primaryjoin = lambda: User.id == Member.user_id)
guild = relationship(Guild)
banned_by = relationship(User, primaryjoin= lambda: User.id == Member.banned_by_id)
@property
def is_banned(self):
return self.banned_at is not None and (self.banned_until is None or self.banned_until <= datetime.datetime.now())
posts = relationship('Post', back_populates='topic')
POST_TYPE_DEFAULT = 0
POST_TYPE_LINK = 1
class Post(Base):
class Post(BaseModel):
__tablename__ = 'freak_post'
__table_args__ = (
UniqueConstraint('id', name='post_id_uniq'),
)
id = snowflake_column()
id = Column(BigInteger, primary_key=True, default=new_id, unique=True)
slug = Column(String(64), CheckConstraint("slug IS NULL OR (slug = lower(slug) AND slug ~ '^[a-z0-9_-]+$')", name='post_slug_valid'), nullable=True)
title = Column(String(256), nullable=False)
@ -346,17 +243,16 @@ class Post(Base):
# utilities
author: Relationship[User] = relationship("User", lazy='selectin', foreign_keys=[author_id])#, back_populates="posts")
guild = relationship("Guild", back_populates="posts", lazy='selectin')
topic = relationship("Topic", back_populates="posts", lazy='selectin')
comments = relationship("Comment", back_populates="parent_post")
upvoters = relationship("User", secondary=PostUpvote, back_populates='upvoted_posts')
def topic_or_user(self) -> Guild | User:
return self.guild or self.author
def topic_or_user(self) -> Topic | User:
return self.topic or self.author
def url(self):
return self.topic_or_user().url() + '/comments/' + Snowflake(self.id).to_b32l() + '/' + (self.slug or '')
return self.topic_or_user().url() + '/comments/' + id_to_b32l(self.id) + '/' + (self.slug or '')
@not_implemented
def generate_slug(self):
return slugify.slugify(self.title, max_length=64)
@ -367,7 +263,7 @@ class Post(Base):
def upvoted_by(self, user: User | AnonymousUserMixin | None):
if not user or not user.is_authenticated:
return 0
v: PostUpvote | None = db.session.execute(select(PostUpvote.c).where(PostUpvote.c.voter_id == user.id, PostUpvote.c.post_id == self.id)).fetchone()
v = db.session.execute(db.select(PostUpvote.c).where(PostUpvote.c.voter_id == user.id, PostUpvote.c.post_id == self.id)).fetchone()
if v:
if v.is_downvote:
return -1
@ -378,7 +274,7 @@ class Post(Base):
return db.session.execute(select(Comment).where(Comment.parent_comment == None, Comment.parent_post == self).order_by(Comment.created_at.desc()).limit(limit)).scalars()
def report_url(self) -> str:
return f'/report/post/{Snowflake(self.id):l}'
return '/report/post/' + id_to_b32l(self.id)
def report_count(self) -> int:
return db.session.execute(select(func.count('*')).select_from(PostReport).where(PostReport.target_id == self.id, ~PostReport.update_status.in_((1, 2)))).scalar()
@ -401,13 +297,12 @@ class Post(Base):
return or_(Post.author_id == user.id, Post.privacy.in_((0, 1)))
class Comment(Base):
class Comment(BaseModel):
__tablename__ = 'freak_comment'
__table_args__ = (
UniqueConstraint('id', name='comment_id_uniq'),
)
id = snowflake_column()
# tweak to allow remote_side to work
## XXX will be changed in 0.4 to suou.id_column()
id = Column(BigInteger, primary_key=True, default=new_id, unique=True)
author_id = Column(BigInteger, ForeignKey('freak_user.id', name='comment_author_id'), nullable=True)
parent_post_id = Column(BigInteger, ForeignKey('freak_post.id', name='comment_parent_post_id'), nullable=False)
@ -418,7 +313,6 @@ class Comment(Base):
updated_at = Column(DateTime, nullable=True)
is_locked = Column(Boolean, server_default=text('false'))
## DO NOT FILL IN! intended for 0.2 or earlier
legacy_id = Column(BigInteger, nullable=True)
removed_at = Column(DateTime, nullable=True)
@ -426,14 +320,15 @@ class Comment(Base):
removed_reason = Column(SmallInteger, nullable=True)
author = relationship('User', foreign_keys=[author_id])#, back_populates='comments')
parent_post: Relationship[Post] = relationship("Post", back_populates="comments", foreign_keys=[parent_post_id])
parent_comment, child_comments = parent_children('comment', parent_remote_side=Wanted('id'))
parent_post = relationship("Post", back_populates="comments", foreign_keys=[parent_post_id])
parent_comment = relationship("Comment", back_populates="child_comments", remote_side=[id])
child_comments = relationship("Comment", back_populates="parent_comment")
def url(self):
return self.parent_post.url() + f'/comment/{Snowflake(self.id):l}'
return self.parent_post.url() + '/comment/' + id_to_b32l(self.id)
def report_url(self) -> str:
return f'/report/comment/{Snowflake(self.id):l}'
return '/report/comment/' + id_to_b32l(self.id)
def report_count(self) -> int:
return db.session.execute(select(func.count('*')).select_from(PostReport).where(PostReport.target_id == self.id, ~PostReport.update_status.in_((1, 2)))).scalar()
@ -446,11 +341,9 @@ class Comment(Base):
def not_removed(cls):
return Post.removed_at == None
class PostReport(Base):
class PostReport(BaseModel):
__tablename__ = 'freak_postreport'
id = snowflake_column()
author_id = Column(BigInteger, ForeignKey('freak_user.id', name='report_author_id'), nullable=True)
target_type = Column(SmallInteger, nullable=False)
target_id = Column(BigInteger, nullable=False)
@ -469,27 +362,6 @@ class PostReport(Base):
else:
return self.target_id
def is_critical(self):
return self.reason_code in (
121, 142, 210
)
class UserStrike(Base):
__tablename__ = 'freak_user_strike'
id = id_column(SiqType.MULTI)
user_id = Column(BigInteger, ForeignKey('freak_user.id', ondelete='cascade'), nullable=False)
target_type = Column(SmallInteger, nullable=False)
target_id = Column(BigInteger, nullable=False)
target_content = Column(String(4096), nullable=True)
reason_code = Column(SmallInteger, nullable=False)
issued_at = Column(DateTime, server_default=func.current_timestamp())
issued_by_id = Column(BigInteger, ForeignKey('freak_user.id'), nullable=True)
user = relationship(User, primaryjoin= lambda: User.id == UserStrike.user_id)
issued_by = relationship(User, primaryjoin= lambda: User.id == UserStrike.issued_by_id)
# PostUpvote table is at the top !!

View file

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

View file

@ -1,6 +1,14 @@
(function(){
"use strict";
// UNUSED! Period is disallowed regardless now
function checkUsername(u){
return (
/^\./.test(u)? 'You cannot start username with a period.':
/\.$/.test(u)? 'You cannot end username with a period.':
/\.\./.test(u)? 'You cannot have more than one period in a row.':
u.match(/\.(com|net|org|txt)$/)? 'Your username cannot end with .' + forbidden_extensions[1]:
'ok'
);
}
function attachUsernameInput(){
@ -132,36 +140,9 @@
}).then(e => e.json());
}
function enableThemeChange() {
let schemeItems = document.querySelectorAll('.apply-theme [name="color_scheme"]');
for (let ii of schemeItems) {
ii.addEventListener('change', function(e) {
let removed_classes = Array.from(document.body.classList).filter((x) => /^color-scheme-/.test(x));
document.body.classList.remove(...removed_classes);
if (e.target.value !== 'unset') {
document.body.classList.add(`color-scheme-${e.target.value}`);
}
console.log(`Color scheme changed to ${e.target.value}`)
})
}
let themeItems = document.querySelectorAll('.apply-theme [name="color_theme"]');
for (let ii of themeItems) {
ii.addEventListener('change', function(e) {
let removed_classes = Array.from(document.body.classList).filter((x) => /^color-theme-/.test(x));
document.body.classList.remove(...removed_classes);
document.body.classList.add(`color-theme-${e.target.value}`);
console.log(`Color theme changed to ${e.target.value}`)
})
}
}
function main() {
attachUsernameInput();
enablePostVotes();
enableThemeChange();
}
main();

View file

@ -5,26 +5,10 @@
box-sizing: border-box
\:root
--c0-accent: #ff7300
--c1-accent: #ff7300
--c2-accent: #f837ce
--c3-accent: #38b8ff
--c4-accent: #ffe338
--c5-accent: #78f038
--c6-accent: #ff9aae
--c7-accent: #606080
--c8-accent: #aeaac0
--c9-accent: #3ae0b8
--c10-accent: #a828ba
--c11-accent: #1871d8
--c12-accent: #885a18
--c13-accent: #38a856
--c14-accent: #ff3018
--c15-accent: #ff1668
--light-text-primary: #181818
--light-text-alt: #444
--light-border: #999
--light-accent: #ff7300
--light-success: #73af00
--light-error: #e04433
--light-canvas: #eaecee
@ -34,21 +18,17 @@
--dark-text-primary: #e8e8e8
--dark-text-alt: #c0cad3
--dark-border: #777
--dark-accent: #ff7300
--dark-success: #93cf00
--dark-error: #e04433
--dark-canvas: #0a0a0e
--dark-background: #181a21
--dark-bg-sharp: #080808
--accent: var(--c0-accent)
// the following are DEPRECATED //
--light-accent: var(--accent)
--dark-accent: var(--accent)
--text-primary: var(--light-text-primary)
--text-alt: var(--light-text-alt)
--border: var(--light-border)
--accent: var(--light-accent)
--success: var(--light-success)
--error: var(--light-error)
--canvas: var(--light-canvas)
@ -60,77 +40,35 @@
--text-primary: var(--dark-text-primary)
--text-alt: var(--dark-text-alt)
--border: var(--dark-border)
--accent: var(--dark-accent)
--success: var(--dark-success)
--error: var(--dark-error)
--canvas: var(--dark-canvas)
--background: var(--dark-background)
--bg-sharp: var(--dark-bg-sharp)
.color-scheme-light
body.color-scheme-light
--text-primary: var(--light-text-primary)
--text-alt: var(--light-text-alt)
--border: var(--light-border)
--accent: var(--light-accent)
--success: var(--light-success)
--error: var(--light-error)
--canvas: var(--light-canvas)
--background: var(--light-background)
--bg-sharp: var(--light-bg-sharp)
.color-scheme-dark
body.color-scheme-dark
--text-primary: var(--dark-text-primary)
--text-alt: var(--dark-text-alt)
--border: var(--dark-border)
--accent: var(--dark-accent)
--success: var(--dark-success)
--error: var(--dark-error)
--canvas: var(--dark-canvas)
--background: var(--dark-background)
--bg-sharp: var(--dark-bg-sharp)
.color-theme-1
--accent: var(--c1-accent)
.color-theme-2
--accent: var(--c2-accent)
.color-theme-3
--accent: var(--c3-accent)
.color-theme-4
--accent: var(--c4-accent)
.color-theme-5
--accent: var(--c5-accent)
.color-theme-6
--accent: var(--c6-accent)
.color-theme-7
--accent: var(--c7-accent)
.color-theme-8
--accent: var(--c8-accent)
.color-theme-9
--accent: var(--c9-accent)
.color-theme-10
--accent: var(--c10-accent)
.color-theme-11
--accent: var(--c11-accent)
.color-theme-12
--accent: var(--c12-accent)
.color-theme-13
--accent: var(--c13-accent)
.color-theme-14
--accent: var(--c14-accent)
.color-theme-15
--accent: var(--c15-accent)
body, input, select, button
font-family: $ui-fonts

View file

@ -29,7 +29,6 @@ blockquote
ul
margin: 4px 0
padding: 0
padding-inline-start: 1.5em
> li
margin: 0

View file

@ -54,11 +54,6 @@ header.header
&, > ul, > ul > li:has(.mini-search-bar)
flex: 1
ul > li span
color: var(--text-primary)
font-size: .6em
.header-username
> *
display: block
@ -108,9 +103,6 @@ aside.card
padding: 12px
margin: -12px -12px 0 -12px
position: relative
a
color: inherit
text-decoration: underline
> ul
list-style: none
margin: 0
@ -140,7 +132,6 @@ ul.inline
list-style: none
padding: 0
margin: 0
display: inline
> li
display: inline
&::before
@ -148,30 +139,12 @@ ul.inline
margin: 0 .5em
&:first-child::before
content: ''
margin: 0
ul.grid
list-style: none
padding: 0
display: grid
grid-template-columns: 1fr 1fr 1fr 1fr
grid-template-rows: auto
> li
border: 1px solid var(--border)
border-radius: .5em
padding: .5em
margin: 1em .5em
text-align: center
small
display: block
ul.message-options
color: var(--text-alt)
list-style: none
padding: 0
font-size: smaller
.comment-frame &
margin-bottom: -4px
.post-frame
@ -183,9 +156,6 @@ ul.message-options
margin-left: 0
margin-right: 3em
.message-options
margin-bottom: 1em
.message-stats
position: absolute
left: -3em
@ -302,7 +272,7 @@ button, [type="submit"], [type="reset"], [type="button"]
&.primary
background-color: var(--accent)
color: var(--background)
color: var(--bg-main)
&[disabled]
opacity: .5
@ -328,27 +298,5 @@ button, [type="submit"], [type="reset"], [type="button"]
width: 0
margin-right: auto
.border-accent
border: var(--accent) 1px solid
display: inline-flex
align-items: center
padding: 0 4px
.round
border-radius: 1em
.done
opacity: .5
button.card
width: 100%
padding: .5em 1em
background-color: transparent
border-color: var(--accent)
color: var(--accent)
border-radius: 1em
&.primary
background-color: var(--accent)
color: var(--background)

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -4,7 +4,6 @@
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
{% from "macros/icon.html" import icon with context %}
{% block title %}
<title>{{ app_name }}</title>
{% endblock %}
@ -15,8 +14,6 @@
This service is age-restricted; do not access if underage.
More info: https://{{ domain_name }}/terms
-->
<meta name="og:site_name" content="{{ app_name }}" />
<meta name="generator" content="{{ app_name }} {{ app_version }}" />
<meta name="csrf_token" content="{{ csrf_token() }}">
<link rel="stylesheet" href="{{ url_for_css('style') }}" />
{# psa: icons url MUST be supplied by .env via PRIVATE_ASSETS= #}
@ -26,7 +23,7 @@
<link rel="icon" href="/favicon.ico" />
<script src="{{ jquery_url }}"></script>
</head>
<body {% if current_user.color_theme %} class="{{ theme_classes(current_user.color_theme) }}"{% endif %}>
<body>
<header class="header">
<h1><a href="/">{{ app_name }}</a></h1>
<div class="metanav">
@ -46,27 +43,29 @@
{% if g.no_user %}
<!-- no user -->
{% elif current_user.is_authenticated %}
<li>
<a class="round border-accent" href="/create" title="Create a post">
<li><a href="/create" title="Create a post">
<i class="icon icon-add"></i>
<span class="a11y">create</span>
<span class="nomobile">New post</span>
</a>
</li>
<li><a href="{{ current_user.url() }}" title="{{ current_user.handle() }}'s profile">{{ icon('profile')}}<span class="a11y">profile</span></a>
</a></li><li><a href="{{ current_user.url() }}"
title="@{{ current_user.username }}'s profile">
<i class="icon icon-profile"></i>
<span class="a11y">profile</span>
</a></li><li>
<div class="header-username">
<strong class="header-username-name">{{ current_user.handle() }}</strong>
<span class="header-username-karma">{{ icon('karma') }} {{ current_user.karma }} karma</span>
</div></li>
<strong class="header-username-name">@{{ current_user.username }}</strong>
<span class="header-username-karma"><i class="icon icon-karma"></i> {{ current_user.karma }} karma</span>
</div>
</li>
<li><a href="/logout" title="Log out">
{{ icon('logout') }} <span class="a11y">log out</span>
<i class="icon icon-logout"></i><span class="a11y">log out</span>
</a></li>
{% else %}
<li><a href="/login" title="Log in">
{{ icon('logout') }}<span class="a11y">log in</span>
</a></li>
<li><a href="/register" title="Register">
{{ icon('join') }}<span class="a11y">register</span>
<i class="icon icon-logout"></i>
<span class="a11y">log in</span>
</a></li><li><a href="/register" title="Register">
<i class="icon icon-join"></i>
<span class="a11y">register</span>
</a></li>
{% endif %}
</ul>
@ -103,9 +102,9 @@
function changeAccentColorTime() {
let hours = (new Date).getHours();
if (hours < 6 || hours >= 19) {
document.body.classList.add('night');
document.body.style.setProperty('--accent', '#1871d8');
} else {
document.body.classList.remove('night');
document.body.style.removeProperty('--accent');
}
}
changeAccentColorTime();

View file

@ -1,7 +1,6 @@
{% extends "base.html" %}
{% from "macros/feed.html" import feed_post, stop_scrolling, no_more_scrolling with context %}
{% from "macros/title.html" import title_tag with context %}
{% from "macros/nav.html" import nav_guild, nav_top_communities with context %}
{# set feed_title = 'For you' if feed_type == 'foryou' and not feed_title %}
{% set feed_title = 'Explore' if feed_type == 'explore' and not feed_title #}
@ -17,11 +16,13 @@
{% block nav %}
{% if top_communities %}
{% from "macros/nav.html" import nav_top_communities with context %}
{{ nav_top_communities(top_communities) }}
{% endif %}
{% if feed_type == 'guild' %}
{{ nav_guild(guild) }}
{% if feed_type == 'topic' %}
{% from "macros/nav.html" import nav_topic with context %}
{{ nav_topic(topic) }}
{% endif %}
<aside class="card">

View file

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

View file

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

View file

@ -1,11 +1,11 @@
{% macro nav_guild(gu) %}
{% macro nav_topic(topic) %}
<aside class="card">
<h3>About <a href="{{ gu.url() }}">{{ gu.handle() }}</a></h3>
<h3>About {{ topic.handle() }}</h3>
<ul>
<li><i class="icon icon-info" style="font-size:inherit"></i> {{ gu.description }}</li>
<li><i class="icon icon-info" style="font-size:inherit"></i> {{ topic.description }}</li>
<li>
<strong>{{ gu.posts | count }}</strong> posts -
<strong>{{ topic.posts | count }}</strong> posts -
<strong>-</strong> subscribers
</li>
</ul>
@ -14,11 +14,9 @@
{% macro nav_user(user) %}
<aside class="card">
<h3>About <a href="{{ user.url() }}">{{ user.display_name or user.handle() }}</a></h3>
<h3>About {{ user.handle() }}</h3>
<ul>
{% if user.biography %}
<li>{{ icon('info') }} {{ user.biography }}</li>
{% endif %}
<li>{# user.biography #}</li>
</ul>
</aside>
{% endmacro %}

View file

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

View file

@ -18,7 +18,7 @@
button{border:1px solid var(--ac);border-radius:6px;color:var(--ac);background-color:transparent;opacity:.8;margin:6px 12px;padding:6px 12px;font:inherit}
button.primary{background-color:var(--ac);color:var(--fg)}
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;}
</style>
</head>

View file

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

View file

@ -3,22 +3,13 @@
{% from "macros/feed.html" import single_comment, feed_upvote, comment_count with context %}
{% from "macros/create.html" import comment_area with context %}
{% from "macros/icon.html" import icon, callout with context %}
{% from "macros/nav.html" import nav_guild, nav_user with context %}
{% block title %}
{{ title_tag(p.title + '; from ' + p.topic_or_user().handle()) }}
<meta name="og:title" content="{{ p.title }}" />
{# meta name="og:description" coming in 0.4 #}
{% if p.author %}
<meta name="author" content="{{ p.author.display_name or p.author.username }}" />
{% endif %}
{% endblock %}
{% block title %}{{ title_tag(p.title + '; from ' + p.topic_or_user().handle()) }}{% endblock %}
{% block nav %}
{% if p.guild %}
{{ nav_guild(p.guild) }}
{% elif p.author %}
{{ nav_user(p.author) }}
{% if p.topic %}
{% from "macros/nav.html" import nav_topic with context %}
{{ nav_topic(p.topic) }}
{% endif %}
{% endblock %}
@ -29,15 +20,12 @@
<h1 class="message-title">{{ p.title }}</h1>
<div class="message-meta">
Posted by <a href="{{ p.author.url() }}">@{{ p.author.username }}</a>
{% if p.guild %}
on <a href="{{ p.guild.url() }}">+{{ p.guild.name }}</a>
{% if p.topic %}
on <a href="{{ p.topic.url() }}">+{{ p.topic.name }}</a>
{% else %}
on their user page
{% endif %}
- <time datetime="{{ p.created_at.isoformat('T') }}">{{ p.created_at.strftime('%B %-d, %Y at %H:%M') }}</time>
{% if p.privacy == 1 %}
- {{ icon('link_post') }} Unlisted
{% endif %}
</div>
{% if current_user.is_administrator and p.report_count() %}
{% call callout() %}
@ -57,6 +45,7 @@
{{ feed_upvote(p.id, p.upvotes(), p.upvoted_by(current_user)) }}
{{ comment_count(p.comments | count) }}
</div>
</div>
<ul class="message-options inline">
{% if p.author == current_user %}
<li><a href="/edit/post/{{ p.id|to_b32l }}"><i class="icon icon-edit"></i> Edit</a></li>
@ -64,8 +53,6 @@
<li><a href="{{ p.report_url() }}"><i class="icon icon-report"></i> Report</a></li>
{% endif %}
</ul>
</div>
{{ comment_area(p.url()) }}
<div class="comment-section">
<ul>

View file

@ -2,24 +2,11 @@
{% from "macros/feed.html" import feed_post, stop_scrolling, no_more_scrolling with context %}
{% from "macros/title.html" import title_tag with context %}
{% from "macros/icon.html" import icon, callout with context %}
{% from "macros/nav.html" import nav_user with context %}
{% block title %}{{ title_tag(user.handle() + 's content') }}{% endblock %}
{% block heading %}
<h2>{{ user.handle() }}</h2>
<ul class="inline">
<li>{{ icon('karma') }} <strong>{{ user.karma }}</strong> karma</li>
<li>Joined at: <time datetime="{{ user.joined_at.isoformat('T') }}">{{ user.joined_at.strftime('%B %-d, %Y %H:%M') }}</time></li>
<li>ID: {{ user.id|to_b32l }}</li>
</ul>
{% endblock %}
{% block nav %}
{{ nav_user(user) }}
{% if user == current_user %}
<a href="/settings"><button class="card">{{ icon('settings') }} Settings</button></a>
{% endif %}
<p>{{ icon('karma') }} <strong>{{ user.karma }}</strong> karma - Joined at: <time datetime="{{ user.joined_at.isoformat('T') }}">{{ user.joined_at.strftime('%B %-d, %Y %H:%M') }}</time> - ID: {{ user.id|to_b32l }}</p>
{% endblock %}
{% block content %}

View file

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

View file

@ -2,6 +2,7 @@
import sys
from flask import Blueprint, render_template, __version__ as flask_version
from sqlalchemy import __version__ as sa_version
from .. import __version__ as app_version
bp = Blueprint('about', __name__)
@ -10,7 +11,8 @@ def about():
return render_template('about.html',
flask_version=flask_version,
sa_version=sa_version,
python_version=sys.version.split()[0]
python_version=sys.version.split()[0],
app_version=app_version
)
@bp.route('/terms/')

View file

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

View file

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

View file

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

View file

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

View file

@ -4,7 +4,6 @@
import datetime
from flask import Blueprint, abort, flash, redirect, render_template, request
from flask_login import current_user, login_required
from sqlalchemy import select
from ..models import Post, db
@ -14,7 +13,7 @@ bp = Blueprint('edit', __name__)
@bp.route('/edit/post/<b32l:id>', methods=['GET', 'POST'])
@login_required
def edit_post(id):
p: Post | None = db.session.execute(select(Post).where(Post.id == id)).scalar()
p: Post | None = db.session.execute(db.select(Post).where(Post.id == id)).scalar()
if p is None:
abort(404)

View file

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

View file

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