diff --git a/freak/__init__.py b/freak/__init__.py index eaa67c4..7b98de3 100644 --- a/freak/__init__.py +++ b/freak/__init__.py @@ -26,7 +26,7 @@ from suou import twocolon_list, WantsContentType from .colors import color_themes, theme_classes -__version__ = '0.5.0-dev42' +__version__ = '0.5.0-dev43' APP_BASE_DIR = os.path.dirname(os.path.dirname(__file__)) @@ -163,6 +163,8 @@ async def error_handler_for(status: int, message: str, template: str): case WantsContentType.JSON: return jsonify({'error': f'{message}', 'status': status}), status 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 case WantsContentType.PLAIN: return f'{message} (HTTP {status})', status, {'content-type': 'text/plain; charset=UTF-8'} diff --git a/freak/accounts.py b/freak/accounts.py index 5ae845c..db34516 100644 --- a/freak/accounts.py +++ b/freak/accounts.py @@ -5,6 +5,7 @@ import enum from sqlalchemy import select from sqlalchemy.orm import selectinload +from suou.sqlalchemy.asyncio import AsyncSession from .models import User, db from quart_auth import AuthUser, Action as _Action @@ -42,7 +43,7 @@ class UserLoader(AuthUser): def __init__(self, auth_id: str | None, action: _Action= _Action.PASS): self._auth_id = auth_id self._auth_obj = None - self._auth_sess = None + self._auth_sess: AsyncSession | None = None self.action = action @property @@ -69,6 +70,10 @@ class UserLoader(AuthUser): def __bool__(self): return self._auth_obj is not None + @property + def session(self): + return self._auth_sess + async def _unload(self): # user is not expected to mutate if self._auth_sess: diff --git a/freak/models.py b/freak/models.py index 392f6ad..d34d567 100644 --- a/freak/models.py +++ b/freak/models.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio from collections import namedtuple import datetime from functools import partial @@ -15,7 +16,7 @@ from sqlalchemy import Column, Integer, String, ForeignKey, UniqueConstraint, an SmallInteger, select, update, Table from sqlalchemy.orm import Relationship, relationship from suou.sqlalchemy_async import SQLAlchemy -from suou import SiqType, Snowflake, Wanted, deprecated, makelist, not_implemented +from suou import SiqType, Snowflake, Wanted, deprecated, makelist, not_implemented, want_isodate from suou.sqlalchemy import create_session, declarative_base, id_column, parent_children, snowflake_column from werkzeug.security import check_password_hash @@ -663,6 +664,17 @@ class Post(Base): 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): __tablename__ = 'freak_comment' __table_args__ = ( @@ -694,6 +706,18 @@ class Comment(Base): def url(self): 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: return f'/report/comment/{Snowflake(self.id):l}' @@ -709,6 +733,21 @@ class Comment(Base): def not_removed(cls): 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): __tablename__ = 'freak_postreport' diff --git a/freak/rest/__init__.py b/freak/rest/__init__.py index 1f66eb5..e89dc85 100644 --- a/freak/rest/__init__.py +++ b/freak/rest/__init__.py @@ -1,15 +1,16 @@ from __future__ import annotations -from typing import Iterable +from typing import Iterable, TypeVar from quart import session from quart import abort, Blueprint, redirect, request, url_for from pydantic import BaseModel -from quart_auth import AuthUser, current_user, login_required, login_user, logout_user -from quart_schema import QuartSchema, validate_request, validate_response -from sqlalchemy import select +from quart_auth import current_user, login_required, login_user, logout_user +from quart_schema import validate_request, validate_response +from sqlalchemy import delete, insert, select from suou import Snowflake, deprecated, makelist, not_implemented, want_isodate +from suou.classtools import MISSING, MissingType from werkzeug.security import check_password_hash from suou.quart import add_rest @@ -17,9 +18,11 @@ from freak.accounts import LoginStatus, check_login from freak.algorithms import public_timeline, top_guilds_query, topic_timeline, user_timeline from freak.search import SearchQuery -from ..models import Guild, Post, User, db +from ..models import Comment, Guild, Post, PostUpvote, User, db from .. import UserLoader, app, app_config, __version__ as freak_version, csrf +_T = TypeVar('_T') + bp = Blueprint('rest', __name__, url_prefix='/v1') rest = add_rest(app, '/v1', '/ajax') @@ -116,7 +119,7 @@ async def user_feed_get(id: int): algo = user_timeline(u) posts = await db.paginate(algo) async for p in posts: - feed.append(p.feed_info()) + feed.append(await p.feed_info_counts()) return dict(users={f'{Snowflake(id):l}': uj}, feed=feed) @@ -155,8 +158,63 @@ async def get_post(id: int): if p.is_text_post(): 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}) +class VoteIn(BaseModel): + vote: int + +@bp.post('/post//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//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 ## async def _guild_info(gu: Guild): @@ -204,7 +262,7 @@ async def guild_feed(gname: str): algo = topic_timeline(gname) posts = await db.paginate(algo) async for p in posts: - feed.append(p.feed_info()) + feed.append(await p.feed_info_counts()) return dict(guilds={f'{Snowflake(gu.id):l}': gj}, feed=feed) @@ -253,7 +311,7 @@ async def home_feed(): posts = await db.paginate(public_timeline()) feed = [] async for post in posts: - feed.append(post.feed_info()) + feed.append(await post.feed_info_counts()) return dict(feed=feed) @@ -277,7 +335,7 @@ async def search_top(data: QueryIn): async with db as session: sq = SearchQuery(data.query) - result: Iterable[Post] = (await session.execute(sq.select(Post, [Post.title]).limit(20))).scalars() + result = (await session.execute(sq.select(Post, [Post.title]).limit(20))).scalars() return dict(has = [p.feed_info() for p in result]) @@ -295,6 +353,42 @@ async def suggest_guild(data: QueryIn): result: Iterable[Guild] = (await session.execute(sq.limit(10))).scalars() - return dict(has = [g.simple_info() for g in result]) + return dict(has = [g.simple_info() for g in result if await g.allows_posting(current_user.user)]) +## 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 diff --git a/freak/templates/admin/400.html b/freak/templates/admin/400.html new file mode 100644 index 0000000..302b34c --- /dev/null +++ b/freak/templates/admin/400.html @@ -0,0 +1,10 @@ +{% extends "admin/admin_base.html" %} + + +{% block content %} +
+

Bad Request

+ +

Back to homepage.

+
+{% endblock %} diff --git a/freak/templates/admin/403.html b/freak/templates/admin/403.html new file mode 100644 index 0000000..d48cc53 --- /dev/null +++ b/freak/templates/admin/403.html @@ -0,0 +1,12 @@ +{% extends "admin/admin_base.html" %} +{% from "macros/title.html" import title_tag with context %} + + + +{% block content %} +
+

Access Denied

+ +

Back to homepage.

+
+{% endblock %} diff --git a/freak/templates/admin/404.html b/freak/templates/admin/404.html new file mode 100644 index 0000000..b7dd7ff --- /dev/null +++ b/freak/templates/admin/404.html @@ -0,0 +1,12 @@ +{% extends "admin/admin_base.html" %} +{% from "macros/title.html" import title_tag with context %} + + + +{% block content %} +
+

Not Found

+ +

Back

+
+{% endblock %} diff --git a/freak/templates/admin/500.html b/freak/templates/admin/500.html new file mode 100644 index 0000000..23e2f50 --- /dev/null +++ b/freak/templates/admin/500.html @@ -0,0 +1,11 @@ +{% extends "admin/admin_base.html" %} + + +{% block content %} +
+

Internal Server Error

+ +

It's on us. Refresh the page.

+
+{% endblock %} + diff --git a/freak/templates/admin/admin_base.html b/freak/templates/admin/admin_base.html index 5288f35..46d9655 100644 --- a/freak/templates/admin/admin_base.html +++ b/freak/templates/admin/admin_base.html @@ -5,7 +5,7 @@ {{ title_tag("Admin") }} - + {% for private_style in private_styles %} {% endfor %} diff --git a/freak/templates/macros/embed.html b/freak/templates/macros/embed.html index c96db78..8b5633c 100644 --- a/freak/templates/macros/embed.html +++ b/freak/templates/macros/embed.html @@ -1,6 +1,6 @@ {% macro embed_post(p) %}
-

{{ p.title }}

+

{{ p.title }}

Posted by @{{ p.author.username }} {% if p.parent_post %} as a comment on post “{{ p.parent_post.title }}” diff --git a/freak/website/admin.py b/freak/website/admin.py index 46cb9b0..accb25e 100644 --- a/freak/website/admin.py +++ b/freak/website/admin.py @@ -2,9 +2,10 @@ import datetime from functools import wraps +import os from typing import Callable import warnings -from quart import Blueprint, abort, redirect, render_template, request, url_for +from quart import Blueprint, abort, redirect, render_template, request, send_from_directory, url_for from quart_auth import current_user from markupsafe import Markup from sqlalchemy import insert, select, update @@ -23,11 +24,11 @@ current_user: UserLoader def admin_required(func: Callable): @wraps(func) - def wrapper(*a, **ka): + async def wrapper(*a, **ka): user: User = current_user.user if not user or not user.is_administrator: abort(403) - return func(*a, **ka) + return await func(*a, **ka) return wrapper @@ -155,10 +156,14 @@ def escalate_report(target, source: PostReport): async def homepage(): 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/') @admin_required async def reports(): - report_list = db.paginate(select(PostReport).order_by(PostReport.id.desc())) + report_list = await db.paginate(select(PostReport).order_by(PostReport.id.desc())) return await render_template('admin/admin_reports.html', report_list=report_list, report_reasons=REPORT_REASON_STRINGS) @@ -169,10 +174,13 @@ async def report_detail(id: int): report = (await session.execute(select(PostReport).where(PostReport.id == id))).scalar() if report is None: abort(404) + target = await report.target() + if target is None: + abort(404) if request.method == 'POST': form = await get_request_form() action = REPORT_ACTIONS[form['do']] - await action(report.target(), report) + await action(target, report) return redirect(url_for('admin.reports')) return await render_template('admin/admin_report_detail.html', report=report, report_reasons=REPORT_REASON_STRINGS) @@ -188,7 +196,7 @@ async def strikes(): @bp.route('/admin/users/') @admin_required async def users(): - user_list = db.paginate(select(User).order_by(User.joined_at.desc())) + user_list = await db.paginate(select(User).order_by(User.joined_at.desc())) return await render_template('admin/admin_users.html', user_list=user_list, account_status_string=colorized_account_status_string) @@ -219,5 +227,7 @@ async def user_detail(id: int): else: abort(400) strikes = (await session.execute(select(UserStrike).where(UserStrike.user_id == id).order_by(UserStrike.id.desc()))).scalars() - return render_template('admin/admin_user_detail.html', u=u, + return await render_template('admin/admin_user_detail.html', u=u, report_reasons=REPORT_REASON_STRINGS, account_status_string=colorized_account_status_string, strikes=strikes) + + diff --git a/freak/website/reports.py b/freak/website/reports.py index f24273c..21b08c5 100644 --- a/freak/website/reports.py +++ b/freak/website/reports.py @@ -5,6 +5,7 @@ from __future__ import annotations from quart import Blueprint, render_template, request from quart_auth import current_user, login_required from sqlalchemy import insert, select +from suou import Snowflake from freak import UserLoader from ..models import REPORT_TARGET_COMMENT, REPORT_TARGET_POST, ReportReason, User, post_report_reasons, Comment, Post, PostReport, REPORT_REASONS, db @@ -35,7 +36,7 @@ async def report_post(id: int): reason_code = REPORT_REASONS[reason] )) session.commit() - return await render_template('reports/report_done.html', back_to_url=p.url()) + return await render_template('reports/report_done.html', back_to_url='/=' + Snowflake(p.id).to_b32l()) return await render_template('reports/report_post.html', id = id, report_reasons = post_report_reasons, description_text=description_text) diff --git a/pyproject.toml b/pyproject.toml index dab79c6..6f60780 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,7 +19,7 @@ dependencies = [ "libsass", "setuptools>=78.1.0", "Hypercorn", - "suou>=0.6.1" + "suou[sqlalchemy]>=0.7.5" ] requires-python = ">=3.10" classifiers = [