diff --git a/app/__init__.py b/app/__init__.py index 2b6297e..8183374 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -3,15 +3,15 @@ from dotenv import load_dotenv from .models import Avatars, Users, PostHistory, Posts, MOTD, BadgeUploads from .auth import digest from .routes.users import is_logged_in, get_active_user, get_prefers_theme -from .routes.threads import get_post_url from .constants import ( PermissionLevel, permission_level_string, InfoboxKind, InfoboxHTMLClass, REACTION_EMOJI, MOTD_BANNED_TAGS, SIG_BANNED_TAGS, STRICT_BANNED_TAGS, ) -from .lib.babycode import babycode_to_html, EMOJI, BABYCODE_VERSION -from datetime import datetime +from .lib.babycode import babycode_to_html, babycode_to_rssxml, EMOJI, BABYCODE_VERSION +from datetime import datetime, timezone +from flask_caching import Cache import os import time import secrets @@ -55,6 +55,18 @@ def reparse_babycode(): print('Re-parsing babycode, this may take a while...') from .db import db from .constants import MOTD_BANNED_TAGS + + post_histories_without_rss = PostHistory.findall([ + ('markup_language', '=', 'babycode'), + ('content_rss', 'IS', None), + ]) + + with db.transaction(): + for ph in post_histories_without_rss: + ph.update({ + 'content_rss': babycode_to_rssxml(ph['original_markup']), + }) + post_histories = PostHistory.findall([ ('markup_language', '=', 'babycode'), ('format_version', 'IS NOT', BABYCODE_VERSION) @@ -65,6 +77,7 @@ def reparse_babycode(): for ph in post_histories: ph.update({ 'content': babycode_to_html(ph['original_markup']).result, + 'content_rss': babycode_to_rssxml(ph['original_markup']), 'format_version': BABYCODE_VERSION, }) print('Re-parsing posts done.') @@ -125,6 +138,8 @@ def bind_default_badges(path): }) +cache = Cache() + def create_app(): app = Flask(__name__) app.config['SITE_NAME'] = 'Pyrom' @@ -133,6 +148,10 @@ def create_app(): app.config['USERS_CAN_INVITE'] = False app.config['ADMIN_CONTACT_INFO'] = '' app.config['GUIDE_DESCRIPTION'] = '' + + app.config['CACHE_TYPE'] = 'FileSystemCache' + app.config['CACHE_DEFAULT_TIMEOUT'] = 300 + try: app.config.from_file('../config/pyrom_config.toml', load=tomllib.load, text=False) except FileNotFoundError: @@ -142,6 +161,7 @@ def create_app(): app.static_folder = os.path.join(os.path.dirname(__file__), "../data/static") app.debug = True app.config["DB_PATH"] = "data/db/db.dev.sqlite" + app.config["SERVER_NAME"] = "localhost:8080" load_dotenv() else: app.config["DB_PATH"] = "data/db/db.prod.sqlite" @@ -156,6 +176,13 @@ def create_app(): os.makedirs(os.path.dirname(app.config["DB_PATH"]), exist_ok = True) os.makedirs(os.path.dirname(app.config["BADGES_UPLOAD_PATH"]), exist_ok = True) + if app.config['CACHE_TYPE'] == 'FileSystemCache': + cache_dir = app.config.get('CACHE_DIR', 'data/_cached') + os.makedirs(cache_dir, exist_ok = True) + app.config['CACHE_DIR'] = cache_dir + + cache.init_app(app) + css_dir = 'data/static/css/' allowed_themes = [] for f in os.listdir(css_dir): @@ -229,10 +256,12 @@ def create_app(): @app.context_processor def inject_funcs(): + from .routes.threads import get_post_url return { 'get_post_url': get_post_url, 'get_prefers_theme': get_prefers_theme, 'get_motds': MOTD.get_all, + 'get_time_now': lambda: int(time.time()), } @app.template_filter("ts_datetime") @@ -308,4 +337,8 @@ def create_app(): def fromjson(subject: str): return json.loads(subject) + @app.template_filter('iso8601') + def unix_to_iso8601(subject: str): + return datetime.fromtimestamp(int(subject), timezone.utc).isoformat() + return app diff --git a/app/lib/babycode.py b/app/lib/babycode.py index 9961c99..ea0eca6 100644 --- a/app/lib/babycode.py +++ b/app/lib/babycode.py @@ -190,7 +190,7 @@ class RSSXMLRenderer(BabycodeRenderer): def __init__(self, fragment=False): super().__init__(RSS_TAGS, VOID_TAGS, RSS_EMOJI, fragment) - def make_mention(self, element): + def make_mention(self, e): from ..models import Users from flask import url_for, current_app with current_app.test_request_context('/'): @@ -410,10 +410,28 @@ def tag_code_rss(children, attr): else: return f'
{children}
' + +def tag_url_rss(children, attr): + if attr.startswith('/'): + from flask import current_app + uri = f"{current_app.config['PREFERRED_URL_SCHEME']}://{current_app.config['SERVER_NAME']}{attr}" + return f"{children}" + + return f"{children}" + +def tag_image_rss(children, attr): + if attr.startswith('/'): + from flask import current_app + uri = f"{current_app.config['PREFERRED_URL_SCHEME']}://{current_app.config['SERVER_NAME']}{attr}" + return f'{children}' + + return f'{children}' + RSS_TAGS = { **TAGS, - 'img': lambda children, attr: f'{children}', - 'spoiler': lambda children, attr: f'
{attr or "Spoiler"}{children}
', + 'img': tag_image_rss, + 'url': tag_url_rss, + 'spoiler': lambda children, attr: f'
{attr or "Spoiler"} (click to reveal){children}
', 'code': tag_code_rss, 'big': lambda children, attr: f'{children}', @@ -424,6 +442,7 @@ VOID_TAGS = { 'lb': lambda attr: '[', 'rb': lambda attr: ']', '@': lambda attr: '@', + '-': lambda attr: '-', } # [img] is considered block for the purposes of collapsing whitespace, @@ -544,7 +563,7 @@ def babycode_ast(s: str, banned_tags=[]): return elements -def babycode_to_html(s: str, banned_tags=[], fragment=False): +def babycode_to_html(s: str, banned_tags=[], fragment=False) -> BabycodeRenderResult: """ transforms a string of babycode into html. @@ -561,7 +580,7 @@ def babycode_to_html(s: str, banned_tags=[], fragment=False): return r.render(ast) -def babycode_to_rssxml(s: str, banned_tags=[], fragment=False): +def babycode_to_rssxml(s: str, banned_tags=[], fragment=False) -> str: """ transforms a string of babycode into rss-compatible x/html. diff --git a/app/lib/render_atom.py b/app/lib/render_atom.py new file mode 100644 index 0000000..5053d7c --- /dev/null +++ b/app/lib/render_atom.py @@ -0,0 +1,10 @@ +from flask import make_response, render_template, request + +def render_atom_template(template, *args, **kwargs): + injects = { + **kwargs, + '__current_page': request.url, + } + r = make_response(render_template(template, *args, **injects)) + r.mimetype = 'application/xml' + return r diff --git a/app/models.py b/app/models.py index 509d260..5c08f27 100644 --- a/app/models.py +++ b/app/models.py @@ -230,6 +230,38 @@ class Topics(Model): return db.query(q, self.id, per_page, (page - 1) * per_page) + def get_threads_with_op_rss(self): + q = """ + SELECT + threads.id, threads.title, threads.slug, threads.created_at, threads.is_locked, threads.is_stickied, + users.username AS started_by, + users.display_name AS started_by_display_name, + ph.content_rss AS original_post_content, + posts.id AS original_post_id + FROM + threads + JOIN users ON users.id = threads.user_id + JOIN ( + SELECT + posts.thread_id, + posts.id, + posts.user_id, + posts.created_at, + posts.current_revision_id, + ROW_NUMBER() OVER (PARTITION BY posts.thread_id ORDER BY posts.created_at ASC) AS rn + FROM + posts + ) posts ON posts.thread_id = threads.id AND posts.rn = 1 + JOIN + post_history ph ON ph.id = posts.current_revision_id + JOIN + users u ON u.id = posts.user_id + WHERE + threads.topic_id = ? + ORDER BY threads.created_at DESC""" + + return db.query(q, self.id) + class Threads(Model): table = "threads" @@ -238,6 +270,10 @@ class Threads(Model): q = Posts.FULL_POSTS_QUERY + " WHERE posts.thread_id = ? ORDER BY posts.created_at ASC LIMIT ? OFFSET ?" return db.query(q, self.id, limit, offset) + def get_posts_rss(self): + q = Posts.FULL_POSTS_QUERY + ' WHERE posts.thread_id = ?' + return db.query(q, self.id) + def locked(self): return bool(self.is_locked) @@ -265,7 +301,7 @@ class Posts(Model): SELECT posts.id, posts.created_at, - post_history.content, post_history.edited_at, + post_history.content, post_history.edited_at, post_history.content_rss, users.username, users.display_name, users.status, avatars.file_path AS avatar_path, posts.thread_id, users.id AS user_id, post_history.original_markup, diff --git a/app/routes/app.py b/app/routes/app.py index 5db25cd..9802d86 100644 --- a/app/routes/app.py +++ b/app/routes/app.py @@ -1,7 +1,18 @@ from flask import Blueprint, redirect, url_for, render_template +from app import cache +from datetime import datetime bp = Blueprint("app", __name__, url_prefix = "/") @bp.route("/") def index(): return redirect(url_for("topics.all_topics")) + +@bp.route("/cache-test") +def cache_test(): + test_value = cache.get('test') + if test_value is None: + test_value = 'cached_value_' + str(datetime.now()) + cache.set('test', test_value, timeout=10) + return f"set cache: {test_value}" + return f"cached: {test_value}" diff --git a/app/routes/threads.py b/app/routes/threads.py index 80d143b..0f6f933 100644 --- a/app/routes/threads.py +++ b/app/routes/threads.py @@ -1,31 +1,35 @@ from flask import ( Blueprint, render_template, request, redirect, url_for, flash, - abort, + abort, current_app, ) from .users import login_required, mod_only, get_active_user, is_logged_in from ..db import db from ..models import Threads, Topics, Posts, Subscriptions, Reactions from ..constants import InfoboxKind +from ..lib.render_atom import render_atom_template from .posts import create_post from slugify import slugify +from app import cache import math import time bp = Blueprint("threads", __name__, url_prefix = "/threads/") -def get_post_url(post_id, _anchor=False): +def get_post_url(post_id, _anchor=False, external=False): post = Posts.find({'id': post_id}) if not post: return "" thread = Threads.find({'id': post.thread_id}) - res = url_for('threads.thread', slug=thread.slug, after=post_id) - if not _anchor: - return res + anchor = None if not _anchor else f'post-{post_id}' - return f"{res}#post-{post_id}" + return url_for('threads.thread', slug=thread.slug, after=post_id, _external=external, _anchor=anchor) + # if not _anchor: + # return res + + # return f"{res}#post-{post_id}" @bp.get("/") @@ -80,9 +84,25 @@ def thread(slug): is_subscribed = is_subscribed, Reactions = Reactions, unread_count = unread_count, + __feedlink = url_for('.thread_atom', slug=slug, _external=True), + __feedtitle = f'replies to {thread.title}', ) +@bp.get("//feed.atom") +@cache.cached(timeout=5 * 60, unless=lambda: current_app.config.get('DEBUG', False)) +def thread_atom(slug): + thread = Threads.find({"slug": slug}) + if not thread: + abort(404) # TODO throw an atom friendly 404 + return + + topic = Topics.find({'id': thread.topic_id}) + posts = thread.get_posts_rss() + + return render_atom_template('threads/thread.atom', thread=thread, topic=topic, posts=posts, get_post_url=get_post_url) + + @bp.post("/") @login_required def reply(slug): diff --git a/app/routes/topics.py b/app/routes/topics.py index 31f170f..729a615 100644 --- a/app/routes/topics.py +++ b/app/routes/topics.py @@ -1,11 +1,13 @@ from flask import ( Blueprint, render_template, request, redirect, url_for, flash, session, - abort, + abort, current_app ) from .users import login_required, mod_only, get_active_user, is_logged_in from ..models import Users, Topics, Threads, Subscriptions from ..constants import InfoboxKind +from ..lib.render_atom import render_atom_template from slugify import slugify +from app import cache import time import math @@ -80,10 +82,27 @@ def topic(slug): subscriptions = subscriptions, topic = target_topic, current_page = page, - page_count = page_count + page_count = page_count, + __feedlink = url_for('.topic_atom', slug=slug, _external=True), + __feedtitle = f'latest threads in {target_topic.name}', ) +@bp.get('//feed.atom') +@cache.cached(timeout=10 * 60, unless=lambda: current_app.config.get('DEBUG', False)) +def topic_atom(slug): + target_topic = Topics.find({ + "slug": slug + }) + if not target_topic: + abort(404) # TODO throw an atom friendly 404 + return + + threads_list = target_topic.get_threads_with_op_rss() + + return render_atom_template('topics/topic.atom', threads_list=threads_list, target_topic=target_topic) + + @bp.get("//edit") @login_required @mod_only(".topic", slug = lambda slug: slug) diff --git a/app/templates/base.atom b/app/templates/base.atom new file mode 100644 index 0000000..4cf9630 --- /dev/null +++ b/app/templates/base.atom @@ -0,0 +1,20 @@ + + + {% if self.title() %} + {% block title %}{% endblock %} + {% else %} + {{ config.SITE_NAME }} + {% endif %} + {% if self.feed_updated() %} + {% block feed_updated %}{% endblock %} + {% else %} + {{ get_time_now() | iso8601 }} + {% endif %} + {{ __current_page }} + + + {% if self.feed_author() %} + {% block feed_author %}{% endblock %} + {% endif %} + {% block content %}{% endblock %} + diff --git a/app/templates/base.html b/app/templates/base.html index 6792ad8..262163c 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -11,6 +11,9 @@ + {% if __feedlink %} + + {% endif %} diff --git a/app/templates/common/icons.html b/app/templates/common/icons.html index 4582473..6502b1b 100644 --- a/app/templates/common/icons.html +++ b/app/templates/common/icons.html @@ -53,3 +53,9 @@ {%- endmacro %} + +{% macro icn_rss(width=24) %} + + + +{% endmacro %} diff --git a/app/templates/common/macros.html b/app/templates/common/macros.html index 5241971..194499a 100644 --- a/app/templates/common/macros.html +++ b/app/templates/common/macros.html @@ -1,4 +1,4 @@ -{% from 'common/icons.html' import icn_image, icn_spoiler, icn_info, icn_lock, icn_warn, icn_error, icn_bookmark, icn_megaphone %} +{% from 'common/icons.html' import icn_image, icn_spoiler, icn_info, icn_lock, icn_warn, icn_error, icn_bookmark, icn_megaphone, icn_rss %} {% macro pager(current_page, page_count) %} {% set left_start = [1, current_page - 5] | max %} {% set right_end = [page_count, current_page + 5] | min %} @@ -359,3 +359,11 @@ {% endmacro %} + +{% macro rss_html_content(html) %} +{{ html }} +{% endmacro %} + +{% macro rss_button(feed) %} +{{ icn_rss(20) }} Subscribe via RSS +{% endmacro %} diff --git a/app/templates/guides/user-guides/99-babycode.html b/app/templates/guides/user-guides/99-babycode.html index ca9e9a0..05d1d0e 100644 --- a/app/templates/guides/user-guides/99-babycode.html +++ b/app/templates/guides/user-guides/99-babycode.html @@ -167,9 +167,18 @@ Your display name

Mentioning a user does not notify them. It is simply a way to link to their profile in your posts.

+
+ {% set hr_example = "some section\n---\nanother section" %} +

Horizontal rules

+

The special --- markup inserts a horizontal separator, also known as a horizontal rule:

+ {{ ("[code]%s[/code]" % hr_example) | babycode | safe }} + Will become + {{ hr_example | babycode(true) | safe}} +

Horizontal rules will always break the current paragraph.

+

Void tags

-

The special void tags [lb], [rb], and [@] will appear as the literal characters [, ], and @ respectively. Unlike other tags, they are self-contained and have no closing equivalent.

+

The special void tags [lb], [rb], [-] and [@] will appear as the literal characters [, ], and @ respectively. Unlike other tags, they are self-contained and have no closing equivalent.

    {% set lbrb = "[color=red]This text will be red[/color]\n\n[lb]color=red[rb]This text won't be red[lb]/color[rb]" %}
  • [lb] and [rb] allow you to use square brackets without them being interpreted as Babycode: @@ -178,6 +187,7 @@ {{ lbrb | babycode | safe }}
  • The [@] tag allows you to use the @ symbol without it being turned into a mention.
  • +
  • The [-] tag allows you to use the - (dash) symbol without it being turned into a rule.
{% endblock %} diff --git a/app/templates/threads/thread.atom b/app/templates/threads/thread.atom new file mode 100644 index 0000000..607c5d4 --- /dev/null +++ b/app/templates/threads/thread.atom @@ -0,0 +1,19 @@ +{% extends 'base.atom' %} +{% from 'common/macros.html' import rss_html_content %} +{% block title %}replies to {{thread.title}}{% endblock %} +{% block canonical_link %}{{url_for('threads.thread', slug=thread.slug, _external=true)}}{% endblock %} +{% block content %} + {% for post in posts %} + {% set post_url = get_post_url(post.id, _anchor=true, external=true) %} + + Re: {{ thread.title }} + + {{ post_url }} + {{ post.edited_at | iso8601 }} + {{rss_html_content(post.content_rss)}} + + {{ post.display_name }} @{{ post.username }} + + + {% endfor %} +{% endblock %} diff --git a/app/templates/threads/thread.html b/app/templates/threads/thread.html index 7b154e9..641ae4e 100644 --- a/app/templates/threads/thread.html +++ b/app/templates/threads/thread.html @@ -1,4 +1,4 @@ -{% from 'common/macros.html' import pager, babycode_editor_form, full_post, bookmark_button %} +{% from 'common/macros.html' import pager, babycode_editor_form, full_post, bookmark_button, rss_button %} {% from 'common/icons.html' import icn_bookmark %} {% extends "base.html" %} {% block title %}{{ thread.title }}{% endblock %} @@ -53,6 +53,7 @@ {% endif %} + {{ rss_button(url_for('threads.thread_atom', slug=thread.slug)) }} {% for post in posts %} diff --git a/app/templates/topics/topic.atom b/app/templates/topics/topic.atom new file mode 100644 index 0000000..53d4cbf --- /dev/null +++ b/app/templates/topics/topic.atom @@ -0,0 +1,20 @@ +{% extends 'base.atom' %} +{% from 'common/macros.html' import rss_html_content %} +{% block title %}latest threads in {{target_topic.name}}{% endblock %} +{% block canonical_link %}{{url_for('topics.topic', slug=target_topic.slug, _external=true)}}{% endblock %} +{% block content %} +{{ target_topic.description }} + {% for thread in threads_list %} + + [new thread] {{ thread.title | escape }} + + + {{ url_for('threads.thread', slug=thread.slug, _external=true)}} + {{ thread.created_at | iso8601 }} + {{rss_html_content(thread.original_post_content)}} + + {{thread.started_by_display_name}} @{{ thread.started_by }} + + + {% endfor %} +{% endblock %} diff --git a/app/templates/topics/topic.html b/app/templates/topics/topic.html index 1254cb7..b114e63 100644 --- a/app/templates/topics/topic.html +++ b/app/templates/topics/topic.html @@ -1,4 +1,4 @@ -{% from 'common/macros.html' import pager, timestamp, motd %} +{% from 'common/macros.html' import pager, timestamp, motd, rss_button %} {% from 'common/icons.html' import icn_lock, icn_sticky %} {% extends "base.html" %} {% block title %}browsing topic {{ topic['name'] }}{% endblock %} @@ -6,7 +6,7 @@