start stubbing out endpoints

This commit is contained in:
2026-04-13 20:03:26 +03:00
parent ce9bca0a75
commit 4aa4e58c58
14 changed files with 304 additions and 48 deletions

View File

@@ -1,7 +1,7 @@
from flask import Flask, session, request, render_template from flask import Flask, session, request, render_template
from dotenv import load_dotenv from dotenv import load_dotenv
from .models import Avatars, Users, PostHistory, Posts, MOTD, BadgeUploads, Sessions from .models import Avatars, Users, PostHistory, Posts, MOTD, BadgeUploads, Sessions
from .auth import digest, is_logged_in from .auth import digest, is_logged_in, get_active_user
from .constants import ( from .constants import (
PermissionLevel, permission_level_string, PermissionLevel, permission_level_string,
InfoboxKind, InfoboxHTMLClass, InfoboxKind, InfoboxHTMLClass,
@@ -10,6 +10,7 @@ from .constants import (
) )
from .lib.babycode import babycode_to_html, babycode_to_rssxml, EMOJI, BABYCODE_VERSION from .lib.babycode import babycode_to_html, babycode_to_rssxml, EMOJI, BABYCODE_VERSION
from .lib.exceptions import SiteNameMissingException from .lib.exceptions import SiteNameMissingException
from .util import get_post_url, dict_to_query_string
from datetime import datetime, timezone from datetime import datetime, timezone
from flask_caching import Cache from flask_caching import Cache
import os import os
@@ -223,8 +224,16 @@ def create_app():
from app.routes.app import bp as app_bp from app.routes.app import bp as app_bp
from app.routes.topics import bp as topics_bp from app.routes.topics import bp as topics_bp
from app.routes.threads import bp as threads_bp
from app.routes.users import bp as users_bp
from app.routes.guides import bp as guides_bp
from app.routes.mod import bp as mod_bp
app.register_blueprint(app_bp) app.register_blueprint(app_bp)
app.register_blueprint(topics_bp) app.register_blueprint(topics_bp)
app.register_blueprint(threads_bp)
app.register_blueprint(users_bp)
app.register_blueprint(guides_bp)
app.register_blueprint(mod_bp)
@app.context_processor @app.context_processor
def inject_constants(): def inject_constants():
@@ -245,12 +254,18 @@ def create_app():
'get_motds': MOTD.get_all, 'get_motds': MOTD.get_all,
'get_time_now': lambda: int(time.time()), 'get_time_now': lambda: int(time.time()),
'is_logged_in': is_logged_in, 'is_logged_in': is_logged_in,
'get_active_user': get_active_user,
'get_post_url': get_post_url,
} }
@app.template_filter('ts_datetime') @app.template_filter('ts_datetime')
def ts_datetime(ts, format): def ts_datetime(ts, format):
return datetime.utcfromtimestamp(ts or int(time.time())).strftime(format) return datetime.utcfromtimestamp(ts or int(time.time())).strftime(format)
@app.template_filter('dict_to_query_string')
def d2q(d):
return dict_to_query_string(d)
@app.template_filter('pluralize') @app.template_filter('pluralize')
def pluralize(subject, num=1, singular = '', plural = 's'): def pluralize(subject, num=1, singular = '', plural = 's'):
if int(num) == 1: if int(num) == 1:

View File

@@ -1,6 +1,7 @@
from flask import session, flash from flask import session, flash
from .models import Sessions from .models import Sessions, Users
from argon2 import PasswordHasher from argon2 import PasswordHasher
import secrets
import time import time
ph = PasswordHasher() ph = PasswordHasher()
@@ -15,9 +16,9 @@ def verify(expected, given):
return False return False
def is_logged_in(): def is_logged_in():
if "pyrom_session_key" not in session: if 'pyrom_session_key' not in session:
return False return False
sess = Sessions.find({"key": session["pyrom_session_key"]}) sess = Sessions.find({'key': session['pyrom_session_key']})
if not sess: if not sess:
return False return False
if sess.expires_at < int(time.time()): if sess.expires_at < int(time.time()):
@@ -26,3 +27,17 @@ def is_logged_in():
# flash('Your session expired.;Please log in again.', InfoboxKind.INFO) # flash('Your session expired.;Please log in again.', InfoboxKind.INFO)
return False return False
return True return True
def get_active_user():
if not is_logged_in():
return None
sess = Sessions.find({'key': session['pyrom_session_key']})
return Users.find({'id': sess.user_id})
def create_session(user_id):
return Sessions.create({
'key': secrets.token_hex(16),
'user_id': user_id,
'expires_at': int(time.time()) + (31 * 24 * 60 * 60),
})

View File

@@ -131,35 +131,41 @@ class Topics(Model):
order_clause = 'ORDER BY threads.is_stickied DESC, latest_post_created_at DESC' order_clause = 'ORDER BY threads.is_stickied DESC, latest_post_created_at DESC'
q = """ q = """
WITH latest_posts AS (
SELECT SELECT
threads.id, threads.title, threads.slug, threads.created_at, threads.is_locked, threads.is_stickied, thread_id,
users.username AS started_by, id AS latest_post_id,
users.display_name AS started_by_display_name, user_id AS latest_post_user_id,
u.username AS latest_post_username, created_at AS latest_post_created_at,
u.display_name AS latest_post_display_name, ROW_NUMBER() OVER (PARTITION BY thread_id ORDER BY created_at DESC) AS rn
ph.content AS latest_post_content, FROM posts
posts.created_at AS latest_post_created_at, ),
posts.id AS latest_post_id post_counts AS (
FROM
threads
JOIN users ON users.id = threads.user_id
JOIN (
SELECT SELECT
posts.thread_id, thread_id,
posts.id, COUNT(*) AS posts_count
posts.user_id, FROM posts
posts.created_at, GROUP BY thread_id
posts.current_revision_id, )
ROW_NUMBER() OVER (PARTITION BY posts.thread_id ORDER BY posts.created_at DESC) AS rn SELECT
FROM threads.title,
posts threads.slug,
) posts ON posts.thread_id = threads.id AND posts.rn = 1 threads.created_at,
JOIN threads.is_locked,
post_history ph ON ph.id = posts.current_revision_id threads.is_stickied,
JOIN starter.username AS started_by,
users u ON u.id = posts.user_id starter.display_name AS started_by_display_name,
WHERE latest_poster.username AS latest_post_username,
threads.topic_id = ? latest_poster.display_name AS latest_post_display_name,
latest_posts.latest_post_created_at,
latest_posts.latest_post_id,
COALESCE(post_counts.posts_count, 0) AS posts_count
FROM threads
JOIN users AS starter ON starter.id = threads.user_id
LEFT JOIN latest_posts ON latest_posts.thread_id = threads.id AND latest_posts.rn = 1
LEFT JOIN users AS latest_poster ON latest_poster.id = latest_posts.latest_post_user_id
LEFT JOIN post_counts ON post_counts.thread_id = threads.id
WHERE threads.topic_id = ?
""" + order_clause + ' LIMIT ? OFFSET ?' """ + order_clause + ' LIMIT ? OFFSET ?'
return db.query(q, self.id, per_page, (page - 1) * per_page) return db.query(q, self.id, per_page, (page - 1) * per_page)

11
app/routes/guides.py Normal file
View File

@@ -0,0 +1,11 @@
from flask import Blueprint
bp = Blueprint('guides', __name__, url_prefix = '/guides/')
@bp.get('/')
def index():
return 'stub'
@bp.get('/contact')
def contact():
return 'stub'

15
app/routes/mod.py Normal file
View File

@@ -0,0 +1,15 @@
from flask import Blueprint
bp = Blueprint('mod', __name__, url_prefix='/mod/')
@bp.get('/')
def index():
return 'stub'
@bp.get('/topics/new')
def new_topic():
return 'stub'
@bp.get('/topics/sort')
def sort_topics():
return 'stub'

11
app/routes/threads.py Normal file
View File

@@ -0,0 +1,11 @@
from flask import Blueprint
bp = Blueprint('threads', __name__, url_prefix='/threads/')
@bp.get('/<slug>')
def thread(slug):
return 'stub'
@bp.get('/new')
def new():
return 'stub'

View File

@@ -1,6 +1,7 @@
from flask import Blueprint, redirect, url_for, render_template from flask import Blueprint, redirect, url_for, render_template, request, session
from ..models import Topics from ..models import Topics, Threads
import math
bp = Blueprint('topics', __name__, url_prefix = '/topics/') bp = Blueprint('topics', __name__, url_prefix = '/topics/')
@@ -12,6 +13,15 @@ def all_topics():
@bp.get('/<slug>') @bp.get('/<slug>')
def topic(slug): def topic(slug):
t = Topics.find({'slug': slug}) t = Topics.find({'slug': slug})
if t: if not t:
return 'yes' return 'stub'
return 'no' sort_by = request.args.get('sort_by', default=session.get('sort_by', default='activity'))
PER_PAGE = 10
threads_count = Threads.count({'topic_id': t.id})
page_count = max(1, math.ceil(threads_count / PER_PAGE))
page = max(1, min(int(request.args.get('page', default=1)), page_count))
return render_template('topics/topic.html', topic=t, threads=t.get_threads(PER_PAGE, page, sort_by), sort_by=sort_by, page=page, page_count=page_count)
@bp.get('/<slug>/feed.atom')
def feed(slug):
return 'stub'

38
app/routes/users.py Normal file
View File

@@ -0,0 +1,38 @@
from flask import Blueprint, redirect, url_for, render_template, request, session
from ..auth import digest, verify, create_session
from ..models import Users
bp = Blueprint('users', __name__, url_prefix='/users/')
@bp.post('/log-in')
def log_in():
user = Users.find({'username': request.form['username']})
if not user:
return "no user"
if not verify(user.password_hash, request.form['password']):
return "no"
sess = create_session(user.id)
session['pyrom_session_key'] = sess.key
return redirect(request.form['return_to'])
@bp.get('/<username>')
def user_page(username):
return 'stub'
@bp.get('/<username>/settings')
def settings(username):
return 'stub'
@bp.get('/<username>/inbox')
def inbox(username):
return 'stub'
@bp.get('/<username>/bookmarks')
def bookmarks(username):
return 'stub'
@bp.get('/sign-up')
def sign_up():
return 'stub'

View File

@@ -1,9 +1,7 @@
<footer class="plank secondary-bg bottom"> <footer class="plank secondary-bg bottom">
<span>Pyrom commit <a href="{{ "https://git.poto.cafe/yagich/pyrom/commit/" + __commit }}">{{ __commit[:8] }}</a></span> <span>Pyrom commit <a href="{{ "https://git.poto.cafe/yagich/pyrom/commit/" + __commit }}">{{ __commit[:8] }}</a></span>
<ul class="horizontal"> <ul class="horizontal">
{#- <li><a href="{{url_for('guides.contact')}}">Contact</a></li>
<li><a href="#">Contact</a></li> <li><a href="{{url_for('guides.index')}}">Guides</a></li>
<li><a href="#">Guides</a></li>
-#}
</ul> </ul>
</footer> </footer>

View File

@@ -1,3 +1,67 @@
{% macro timestamp(unix_ts) -%} {% macro timestamp(unix_ts) -%}
<span class="timestamp" data-utc="{{ unix_ts }}">{{ unix_ts | ts_datetime('%Y-%m-%d %H:%M')}} <abbr title="Server Time">ST</abbr></span> <span class="timestamp" data-utc="{{ unix_ts }}">{{ unix_ts | ts_datetime('%Y-%m-%d %H:%M')}} <abbr title="Server Time">ST</abbr></span>
{%- endmacro %} {%- endmacro %}
{% macro subheader(title, desc='') -%}
<div id="subheader" class="plank secondary-bg">
<h1 class="info">{{title}}</h1>
{%- if desc -%}<span>{{desc}}</span>{%- endif -%}
<div class="actions-group">{% if caller %}{{- caller() -}}{% endif %}</div>
</div>
{%- endmacro %}
{% macro pager(current_page, page_count, classes='', url='', args={}) -%}
{%- if args -%}
{#- remove the page query argument -#}
{%- set fargs = dict(args.items() | rejectattr(0, 'equalto', 'page')) -%}
{%- set url = url + (fargs | dict_to_query_string) + '&page=' -%}
{%- else -%}
{%- set url = url + '?page=' -%}
{%- endif -%}
<span class="button-row {{classes}}">
{%- if current_page == 0 -%}
{%- if page_count <= 3 -%}
{%- for i in range(page_count) -%}
<a href="{{url}}{{i+1}}" class="linkbutton minimal">{{i+1}}</a>
{%- endfor -%}
{%- else -%}
<a href="{{url}}1" class="linkbutton minimal">1</a>
<a href="{{url}}2" class="linkbutton minimal">2</a>
<button class="minimal" disabled>&hellip;</button>
<a href="{{url}}{{page_count - 1}}" class="linkbutton minimal">{{page_count - 1}}</a>
<a href="{{url}}{{page_count}}" class="linkbutton minimal">{{page_count}}</a>
{%- endif -%}
{%- else -%}
{%- set left_start = [2, current_page - 1] | max -%}
{%- set right_end = [page_count - 1, current_page + 1] | min -%}
{%- if current_page != 1 -%}
<a href="{{url}}1" class="linkbutton minimal">1</a>
{%- endif -%}
{%- if left_start > 2 -%}
<button class="minimal" disabled>&hellip;</button>
{%- endif -%}
{%- for i in range(left_start, current_page) -%}
<a href="{{url}}{{i}}" class="linkbutton minimal">{{i}}</a>
{%- endfor -%}
{%- if page_count > 0 -%}
<button class="minimal" disabled>{{current_page}}</button>
{%- endif -%}
{%- for i in range(current_page + 1, right_end + 1) -%}
<a href="{{url}}{{i}}" class="linkbutton minimal">{{i}}</a>
{%- endfor -%}
{%- if right_end < page_count - 1 -%}
<button class="minimal" disabled>&hellip;</button>
{%- endif -%}
{%- if page_count > 1 and current_page != page_count -%}
<a href="{{url}}{{page_count}}" class="linkbutton minimal">{{page_count}}</a>
{%- endif -%}
{%- endif -%}
</span>
{%- endmacro %}

View File

@@ -1,15 +1,25 @@
<nav id="header" class="plank top"> <nav id="header" class="plank top">
<a class="site-title" href=#>Porom</a> <a class="site-title" href="/">Porom</a>
<span>anti-social media</span> <span>anti-social media</span>
{%- if is_logged_in() -%} {%- if is_logged_in() -%}
no {%- with user = get_active_user() -%}
<ul class="horizontal wrap">
<li class="mobile-fill-flex">Welcome, <a href="{{url_for('users.user_page', username=user.username)}}">{{ user.get_readable_name() }}</a></li>
<li><a class="linkbutton" href="{{url_for('users.settings', username=user.username)}}">Settings</a></li>
<li><a class="linkbutton" href="{{url_for('users.inbox', username=user.username)}}">Inbox</a></li>
<li><a class="linkbutton" href="{{url_for('users.bookmarks', username=user.username)}}">Bookmarks</a></li>
{% if user.is_mod() -%}
<li><a class="linkbutton" href="{{url_for('mod.index')}}">Moderation</a></li>
{%- endif %}
</ul>
{%- endwith -%}
{%- else -%} {%- else -%}
<form class="horizontal wrap"> <form class="horizontal wrap" method="POST" action="{{url_for('users.log_in')}}">
<input type="hidden" name="return_to" value="{{request.path}}"> <input type="hidden" name="return_to" value="{{request.path}}">
<input type="text" placeholder="Username"> <input type="text" placeholder="Username" name="username" autocomplete="username" required>
<input type="password" placeholder="Password"> <input type="password" placeholder="Password" name="password" autocomplete="current-password" required>
<input type="submit" value="Log in"> <input type="submit" value="Log in">
<a href="#" class="linkbutton">Register</a> <a href="{{url_for('users.sign_up')}}" class="linkbutton">Sign up</a>
</form> </form>
{%- endif -%} {%- endif -%}
</nav> </nav>

View File

@@ -0,0 +1,38 @@
{% from 'common/macros.html' import timestamp, subheader, pager %}
{%- extends 'base.html' -%}
{%- block content -%}
{%- call() subheader(('Threads in "%s"' % topic.name), topic.description) -%}
<fieldset class="plank even no-shadow minimal thread-actions">
<legend>Actions</legend>
<a href="{{url_for('threads.new', topic_id=topic.id)}}" class="linkbutton">New thread</a>
<a href="{{url_for('topics.feed', slug=topic.slug)}}" class="linkbutton rss">Subscribe via RSS</a>
<form method="GET">
<select name="sort_by">
<option value="activity"{% if sort_by == 'activity' %}selected{% endif %}>Sorted by activity</option>
<option value="thread" {% if sort_by == 'thread' %}selected{% endif %}>Sorted by newest</option>
</select>
<input type="submit" value="Sort">
</form>
</fieldset>
{%- if get_active_user().is_mod() -%}
<fieldset class="plank even no-shadow minimal thread-actions">
<legend>Moderation actions</legend>
</fieldset>
{%- endif -%}
{%- endcall -%}
{%- for thread in threads -%}
<div class="topic-info plank">
<div class="title-container">
<span class="info thread-title-counter"><a href="{{url_for('threads.thread', slug=thread.slug)}}">{{thread.title}}</a></span>
<ul class="horizontal"></ul>
{%- if thread.posts_count / 10 > 1 -%}
{{pager(0, (((thread.posts_count / 10) | round(0, 'ceil') )| int), 'flex-last', url=url_for('threads.thread', slug=thread.slug))}}
{%- endif -%}
</div>
<span>Started by <a href="{{url_for('users.user_page', username=thread.started_by)}}">{{thread.started_by_display_name if thread.started_by_display_name else thread.started_by}}</a> on {{timestamp(thread.created_at)}}</span>
<span>{{thread.posts_count - 1}} {{'repl' | pluralize(thread.posts_count - 1, 'y', 'ies')}}</span>
<span>Latest reply by <a href="{{get_post_url(thread.latest_post_id, _anchor=true)}}">{{thread.latest_post_display_name if thread.latest_post_display_name else thread.latest_post_username}} on {{timestamp(thread.latest_post_created_at)}}</a></span>
</div>
{%- endfor -%}
{{pager(page, page_count, args=request.args)}}
{%- endblock -%}

View File

@@ -1,6 +1,15 @@
{% from 'common/macros.html' import timestamp %} {% from 'common/macros.html' import timestamp, subheader %}
{%- extends 'base.html' -%} {%- extends 'base.html' -%}
{%- block content -%} {%- block content -%}
{%- call() subheader('All topics') -%}
{%- if get_active_user().is_mod() -%}
<fieldset class="plank even no-shadow minimal thread-actions">
<legend>Moderation actions</legend>
<a href="{{url_for('mod.new_topic')}}" class="linkbutton">New topic</a>
<a href="{{url_for('mod.sort_topics')}}" class="linkbutton">Sort topics</a>
</fieldset>
{%- endif -%}
{%- endcall -%}
{%- for topic in topics -%} {%- for topic in topics -%}
<div class="topic-info plank"> <div class="topic-info plank">
<div class="title-container"> <div class="title-container">

16
app/util.py Normal file
View File

@@ -0,0 +1,16 @@
from flask import url_for
from .models import Posts, Threads
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})
anchor = None if not _anchor else f'post-{post_id}'
return url_for('threads.thread', slug=thread.slug, after=post_id, _external=external, _anchor=anchor)
def dict_to_query_string(d) -> str:
return '?' + '&'.join([f'{key}={str(value)}' for key, value in d.items()])