Compare commits

..

No commits in common. "d1f33afe0906b64e6a8ea5e31622f5019fdbe6e2" and "6b11bf4537432ad06146a83e4044e9d664597111" have entirely different histories.

13 changed files with 24 additions and 220 deletions

View file

@ -26,7 +26,7 @@ from suou import twocolon_list, WantsContentType
from .colors import color_themes, theme_classes from .colors import color_themes, theme_classes
__version__ = '0.5.0-dev43' __version__ = '0.5.0-dev42'
APP_BASE_DIR = os.path.dirname(os.path.dirname(__file__)) APP_BASE_DIR = os.path.dirname(os.path.dirname(__file__))
@ -163,8 +163,6 @@ async def error_handler_for(status: int, message: str, template: str):
case WantsContentType.JSON: case WantsContentType.JSON:
return jsonify({'error': f'{message}', 'status': status}), status return jsonify({'error': f'{message}', 'status': status}), status
case WantsContentType.HTML: case WantsContentType.HTML:
if request.path.startswith('/admin'):
return await render_template('admin/' + template, message=f'{message}'), status
return await render_template(template, message=f'{message}'), status return await render_template(template, message=f'{message}'), status
case WantsContentType.PLAIN: case WantsContentType.PLAIN:
return f'{message} (HTTP {status})', status, {'content-type': 'text/plain; charset=UTF-8'} return f'{message} (HTTP {status})', status, {'content-type': 'text/plain; charset=UTF-8'}

View file

@ -5,7 +5,6 @@ import enum
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from suou.sqlalchemy.asyncio import AsyncSession
from .models import User, db from .models import User, db
from quart_auth import AuthUser, Action as _Action from quart_auth import AuthUser, Action as _Action
@ -43,7 +42,7 @@ class UserLoader(AuthUser):
def __init__(self, auth_id: str | None, action: _Action= _Action.PASS): def __init__(self, auth_id: str | None, action: _Action= _Action.PASS):
self._auth_id = auth_id self._auth_id = auth_id
self._auth_obj = None self._auth_obj = None
self._auth_sess: AsyncSession | None = None self._auth_sess = None
self.action = action self.action = action
@property @property
@ -70,10 +69,6 @@ class UserLoader(AuthUser):
def __bool__(self): def __bool__(self):
return self._auth_obj is not None return self._auth_obj is not None
@property
def session(self):
return self._auth_sess
async def _unload(self): async def _unload(self):
# user is not expected to mutate # user is not expected to mutate
if self._auth_sess: if self._auth_sess:

View file

@ -2,7 +2,6 @@
from __future__ import annotations from __future__ import annotations
import asyncio
from collections import namedtuple from collections import namedtuple
import datetime import datetime
from functools import partial from functools import partial
@ -16,7 +15,7 @@ from sqlalchemy import Column, Integer, String, ForeignKey, UniqueConstraint, an
SmallInteger, select, update, Table SmallInteger, select, update, Table
from sqlalchemy.orm import Relationship, relationship from sqlalchemy.orm import Relationship, relationship
from suou.sqlalchemy_async import SQLAlchemy from suou.sqlalchemy_async import SQLAlchemy
from suou import SiqType, Snowflake, Wanted, deprecated, makelist, not_implemented, want_isodate from suou import SiqType, Snowflake, Wanted, deprecated, makelist, not_implemented
from suou.sqlalchemy import create_session, declarative_base, id_column, parent_children, snowflake_column 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
@ -664,17 +663,6 @@ class Post(Base):
created_at = self.created_at created_at = self.created_at
) )
async def feed_info_counts(self):
pj = self.feed_info()
if self.is_text_post():
pj['content'] = self.text_content[:181]
(pj['comment_count'], pj['votes'], pj['my_vote']) = await asyncio.gather(
self.comment_count(),
self.upvotes(),
self.upvoted_by(current_user.user)
)
return pj
class Comment(Base): class Comment(Base):
__tablename__ = 'freak_comment' __tablename__ = 'freak_comment'
__table_args__ = ( __table_args__ = (
@ -706,18 +694,6 @@ class Comment(Base):
def url(self): def url(self):
return self.parent_post.url() + f'/comment/{Snowflake(self.id):l}' return self.parent_post.url() + f'/comment/{Snowflake(self.id):l}'
async def is_parent_locked(self):
if self.is_locked:
return True
if self.parent_comment_id == None:
return False
async with db as session:
parent = (await session.execute(select(Comment).where(Comment.id == self.parent_comment_id))).scalar()
try:
return parent.is_parent_locked()
except RecursionError:
return True
def report_url(self) -> str: def report_url(self) -> str:
return f'/report/comment/{Snowflake(self.id):l}' return f'/report/comment/{Snowflake(self.id):l}'
@ -733,21 +709,6 @@ class Comment(Base):
def not_removed(cls): def not_removed(cls):
return Post.removed_at == None return Post.removed_at == None
async def section_info(self):
obj = dict(
id = Snowflake(self.id).to_b32l(),
parent = dict(id=Snowflake(self.parent_comment_id)) if self.parent_comment_id else None,
locked = await self.is_parent_locked(),
created_at = want_isodate(self.created_at)
)
if self.is_removed:
obj['removed'] = self.removed_reason
else:
obj['content'] = self.text_content
return obj
class PostReport(Base): class PostReport(Base):
__tablename__ = 'freak_postreport' __tablename__ = 'freak_postreport'

View file

@ -1,16 +1,15 @@
from __future__ import annotations from __future__ import annotations
from typing import Iterable, TypeVar from typing import Iterable
from quart import session from quart import session
from quart import abort, Blueprint, redirect, request, url_for from quart import abort, Blueprint, redirect, request, url_for
from pydantic import BaseModel from pydantic import BaseModel
from quart_auth import current_user, login_required, login_user, logout_user from quart_auth import AuthUser, current_user, login_required, login_user, logout_user
from quart_schema import validate_request, validate_response from quart_schema import QuartSchema, validate_request, validate_response
from sqlalchemy import delete, insert, select from sqlalchemy import select
from suou import Snowflake, deprecated, makelist, not_implemented, want_isodate from suou import Snowflake, deprecated, makelist, not_implemented, want_isodate
from suou.classtools import MISSING, MissingType
from werkzeug.security import check_password_hash from werkzeug.security import check_password_hash
from suou.quart import add_rest from suou.quart import add_rest
@ -18,11 +17,9 @@ from freak.accounts import LoginStatus, check_login
from freak.algorithms import public_timeline, top_guilds_query, topic_timeline, user_timeline from freak.algorithms import public_timeline, top_guilds_query, topic_timeline, user_timeline
from freak.search import SearchQuery from freak.search import SearchQuery
from ..models import Comment, Guild, Post, PostUpvote, User, db from ..models import Guild, Post, User, db
from .. import UserLoader, app, app_config, __version__ as freak_version, csrf from .. import UserLoader, app, app_config, __version__ as freak_version, csrf
_T = TypeVar('_T')
bp = Blueprint('rest', __name__, url_prefix='/v1') bp = Blueprint('rest', __name__, url_prefix='/v1')
rest = add_rest(app, '/v1', '/ajax') rest = add_rest(app, '/v1', '/ajax')
@ -119,7 +116,7 @@ async def user_feed_get(id: int):
algo = user_timeline(u) algo = user_timeline(u)
posts = await db.paginate(algo) posts = await db.paginate(algo)
async for p in posts: async for p in posts:
feed.append(await p.feed_info_counts()) feed.append(p.feed_info())
return dict(users={f'{Snowflake(id):l}': uj}, feed=feed) return dict(users={f'{Snowflake(id):l}': uj}, feed=feed)
@ -158,63 +155,8 @@ async def get_post(id: int):
if p.is_text_post(): if p.is_text_post():
pj['content'] = p.text_content pj['content'] = p.text_content
pj['comment_count'] = await p.comment_count()
pj['votes'] = await p.upvotes()
pj['my_vote'] = await p.upvoted_by(current_user.user)
return dict(posts={f'{Snowflake(id):l}': pj}) return dict(posts={f'{Snowflake(id):l}': pj})
class VoteIn(BaseModel):
vote: int
@bp.post('/post/<b32l:id>/upvote')
@validate_request(VoteIn)
async def upvote_post(id: int, data: VoteIn):
async with db as session:
p: Post | None = (await session.execute(select(Post).where(Post.id == id))).scalar()
if p is None:
return { 'status': 404, 'error': 'Post not found' }, 404
cur_score = await p.upvoted_by(current_user.user)
match (data.vote, cur_score):
case (1, 0) | (1, -1):
await session.execute(delete(PostUpvote).where(PostUpvote.c.post_id == p.id, PostUpvote.c.voter_id == current_user.id, PostUpvote.c.is_downvote == True))
await session.execute(insert(PostUpvote).values(post_id = p.id, voter_id = current_user.id, is_downvote = False))
case (0, _):
await session.execute(delete(PostUpvote).where(PostUpvote.c.post_id == p.id, PostUpvote.c.voter_id == current_user.id))
case (-1, 1) | (-1, 0):
await session.execute(delete(PostUpvote).where(PostUpvote.c.post_id == p.id, PostUpvote.c.voter_id == current_user.id, PostUpvote.c.is_downvote == False))
await session.execute(insert(PostUpvote).values(post_id = p.id, voter_id = current_user.id, is_downvote = True))
case (1, 1) | (1, -1):
pass
case _:
await session.rollback()
return { 'status': 400, 'error': 'Invalid score' }, 400
await session.commit()
return { 'votes': await p.upvotes() }
## COMMENTS ##
@bp.get('/post/<b32l:id>/comments')
async def post_comments (id: int):
async with db as session:
p: Post | None = (await session.execute(select(Post).where(Post.id == id))).scalar()
if p is None:
return { 'status': 404, 'error': 'Post not found' }, 404
l = []
for com in await p.top_level_comments():
com: Comment
l.append(await com.section_info())
return dict(has=l)
## GUILDS ## ## GUILDS ##
async def _guild_info(gu: Guild): async def _guild_info(gu: Guild):
@ -262,7 +204,7 @@ async def guild_feed(gname: str):
algo = topic_timeline(gname) algo = topic_timeline(gname)
posts = await db.paginate(algo) posts = await db.paginate(algo)
async for p in posts: async for p in posts:
feed.append(await p.feed_info_counts()) feed.append(p.feed_info())
return dict(guilds={f'{Snowflake(gu.id):l}': gj}, feed=feed) return dict(guilds={f'{Snowflake(gu.id):l}': gj}, feed=feed)
@ -311,7 +253,7 @@ async def home_feed():
posts = await db.paginate(public_timeline()) posts = await db.paginate(public_timeline())
feed = [] feed = []
async for post in posts: async for post in posts:
feed.append(await post.feed_info_counts()) feed.append(post.feed_info())
return dict(feed=feed) return dict(feed=feed)
@ -335,7 +277,7 @@ async def search_top(data: QueryIn):
async with db as session: async with db as session:
sq = SearchQuery(data.query) sq = SearchQuery(data.query)
result = (await session.execute(sq.select(Post, [Post.title]).limit(20))).scalars() result: Iterable[Post] = (await session.execute(sq.select(Post, [Post.title]).limit(20))).scalars()
return dict(has = [p.feed_info() for p in result]) return dict(has = [p.feed_info() for p in result])
@ -353,42 +295,6 @@ async def suggest_guild(data: QueryIn):
result: Iterable[Guild] = (await session.execute(sq.limit(10))).scalars() result: Iterable[Guild] = (await session.execute(sq.limit(10))).scalars()
return dict(has = [g.simple_info() for g in result if await g.allows_posting(current_user.user)]) return dict(has = [g.simple_info() for g in result])
## SETTINGS
@bp.get("/settings/appearance")
@login_required
async def get_settings_appearance():
return dict(
color_theme = current_user.user.color_theme
)
class SettingsAppearanceIn(BaseModel):
color_theme : int | None = None
color_scheme : int | None = None
def _missing_or(obj: _T | MissingType, obj2: _T) -> _T:
if obj is None:
return obj2
return obj
@bp.patch("/settings/appearance")
@login_required
@validate_request(SettingsAppearanceIn)
async def patch_settings_appearance(data: SettingsIn):
u = current_user.user
if u is None:
abort(401)
u.color_theme = (
_missing_or(data.color_theme, u.color_theme % (1 << 8)) % 256 +
_missing_or(data.color_scheme, u.color_theme >> 8) << 8
)
current_user.session.add(u)
await current_user.session.commit()
return '', 204

View file

@ -1,10 +0,0 @@
{% extends "admin/admin_base.html" %}
{% block content %}
<div class="centered">
<h2>Bad Request</h2>
<p><a href="/">Back to homepage.</a></p>
</div>
{% endblock %}

View file

@ -1,12 +0,0 @@
{% extends "admin/admin_base.html" %}
{% from "macros/title.html" import title_tag with context %}
{% block content %}
<div class="centered">
<h2>Access Denied</h2>
<p><a href="/">Back to homepage.</a></p>
</div>
{% endblock %}

View file

@ -1,12 +0,0 @@
{% extends "admin/admin_base.html" %}
{% from "macros/title.html" import title_tag with context %}
{% block content %}
<div class="centered">
<h2>Not Found</h2>
<p><a href="/admin/">Back</a></p>
</div>
{% endblock %}

View file

@ -1,11 +0,0 @@
{% extends "admin/admin_base.html" %}
{% block content %}
<div class="centered">
<h2>Internal Server Error</h2>
<p>It's on us. <a href="javascript:history.go(0)">Refresh the page</a>.</p>
</div>
{% endblock %}

View file

@ -5,7 +5,7 @@
{{ title_tag("Admin") }} {{ title_tag("Admin") }}
<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="/admin/style.css"> <link rel="stylesheet" type="text/css" href="/static/css/style.css">
{% for private_style in private_styles %} {% for private_style in private_styles %}
<link rel="stylesheet" href="{{ private_style }}" /> <link rel="stylesheet" href="{{ private_style }}" />
{% endfor %} {% endfor %}

View file

@ -1,6 +1,6 @@
{% macro embed_post(p) %} {% macro embed_post(p) %}
<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.id | to_b32l }}">{{ 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.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>

View file

@ -2,10 +2,9 @@
import datetime import datetime
from functools import wraps from functools import wraps
import os
from typing import Callable from typing import Callable
import warnings import warnings
from quart import Blueprint, abort, redirect, render_template, request, send_from_directory, url_for from quart import Blueprint, abort, redirect, render_template, request, url_for
from quart_auth import current_user from quart_auth import current_user
from markupsafe import Markup from markupsafe import Markup
from sqlalchemy import insert, select, update from sqlalchemy import insert, select, update
@ -24,11 +23,11 @@ current_user: UserLoader
def admin_required(func: Callable): def admin_required(func: Callable):
@wraps(func) @wraps(func)
async def wrapper(*a, **ka): def wrapper(*a, **ka):
user: User = current_user.user user: User = current_user.user
if not user or not user.is_administrator: if not user or not user.is_administrator:
abort(403) abort(403)
return await func(*a, **ka) return func(*a, **ka)
return wrapper return wrapper
@ -156,14 +155,10 @@ def escalate_report(target, source: PostReport):
async def homepage(): async def homepage():
return await render_template('admin/admin_home.html') return await render_template('admin/admin_home.html')
@bp.route('/admin/style.css')
async def style_css():
return await send_from_directory(os.path.dirname(os.path.dirname(__file__)) + '/static/css', 'style.css')
@bp.route('/admin/reports/') @bp.route('/admin/reports/')
@admin_required @admin_required
async def reports(): async def reports():
report_list = await db.paginate(select(PostReport).order_by(PostReport.id.desc())) report_list = db.paginate(select(PostReport).order_by(PostReport.id.desc()))
return await render_template('admin/admin_reports.html', return await render_template('admin/admin_reports.html',
report_list=report_list, report_reasons=REPORT_REASON_STRINGS) report_list=report_list, report_reasons=REPORT_REASON_STRINGS)
@ -174,13 +169,10 @@ async def report_detail(id: int):
report = (await session.execute(select(PostReport).where(PostReport.id == id))).scalar() report = (await session.execute(select(PostReport).where(PostReport.id == id))).scalar()
if report is None: if report is None:
abort(404) abort(404)
target = await report.target()
if target is None:
abort(404)
if request.method == 'POST': if request.method == 'POST':
form = await get_request_form() form = await get_request_form()
action = REPORT_ACTIONS[form['do']] action = REPORT_ACTIONS[form['do']]
await action(target, report) await action(report.target(), report)
return redirect(url_for('admin.reports')) return redirect(url_for('admin.reports'))
return await render_template('admin/admin_report_detail.html', report=report, return await render_template('admin/admin_report_detail.html', report=report,
report_reasons=REPORT_REASON_STRINGS) report_reasons=REPORT_REASON_STRINGS)
@ -196,7 +188,7 @@ async def strikes():
@bp.route('/admin/users/') @bp.route('/admin/users/')
@admin_required @admin_required
async def users(): async def users():
user_list = await db.paginate(select(User).order_by(User.joined_at.desc())) user_list = db.paginate(select(User).order_by(User.joined_at.desc()))
return await render_template('admin/admin_users.html', return await render_template('admin/admin_users.html',
user_list=user_list, account_status_string=colorized_account_status_string) user_list=user_list, account_status_string=colorized_account_status_string)
@ -227,7 +219,5 @@ async def user_detail(id: int):
else: else:
abort(400) abort(400)
strikes = (await session.execute(select(UserStrike).where(UserStrike.user_id == id).order_by(UserStrike.id.desc()))).scalars() strikes = (await session.execute(select(UserStrike).where(UserStrike.user_id == id).order_by(UserStrike.id.desc()))).scalars()
return await render_template('admin/admin_user_detail.html', u=u, return render_template('admin/admin_user_detail.html', u=u,
report_reasons=REPORT_REASON_STRINGS, account_status_string=colorized_account_status_string, strikes=strikes) report_reasons=REPORT_REASON_STRINGS, account_status_string=colorized_account_status_string, strikes=strikes)

View file

@ -5,7 +5,6 @@ from __future__ import annotations
from quart import Blueprint, render_template, request from quart import Blueprint, render_template, request
from quart_auth import current_user, login_required from quart_auth import current_user, login_required
from sqlalchemy import insert, select from sqlalchemy import insert, select
from suou import Snowflake
from freak import UserLoader from freak import UserLoader
from ..models import REPORT_TARGET_COMMENT, REPORT_TARGET_POST, ReportReason, User, post_report_reasons, Comment, Post, PostReport, REPORT_REASONS, db from ..models import REPORT_TARGET_COMMENT, REPORT_TARGET_POST, ReportReason, User, post_report_reasons, Comment, Post, PostReport, REPORT_REASONS, db
@ -36,7 +35,7 @@ async def report_post(id: int):
reason_code = REPORT_REASONS[reason] reason_code = REPORT_REASONS[reason]
)) ))
session.commit() session.commit()
return await render_template('reports/report_done.html', back_to_url='/=' + Snowflake(p.id).to_b32l()) return await render_template('reports/report_done.html', back_to_url=p.url())
return await render_template('reports/report_post.html', id = id, return await render_template('reports/report_post.html', id = id,
report_reasons = post_report_reasons, description_text=description_text) report_reasons = post_report_reasons, description_text=description_text)

View file

@ -19,7 +19,7 @@ dependencies = [
"libsass", "libsass",
"setuptools>=78.1.0", "setuptools>=78.1.0",
"Hypercorn", "Hypercorn",
"suou[sqlalchemy]>=0.7.5" "suou>=0.6.1"
] ]
requires-python = ">=3.10" requires-python = ">=3.10"
classifiers = [ classifiers = [