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 dotenv import load_dotenv
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 (
PermissionLevel, permission_level_string,
InfoboxKind, InfoboxHTMLClass,
@@ -10,6 +10,7 @@ from .constants import (
)
from .lib.babycode import babycode_to_html, babycode_to_rssxml, EMOJI, BABYCODE_VERSION
from .lib.exceptions import SiteNameMissingException
from .util import get_post_url, dict_to_query_string
from datetime import datetime, timezone
from flask_caching import Cache
import os
@@ -223,8 +224,16 @@ def create_app():
from app.routes.app import bp as app_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(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
def inject_constants():
@@ -245,12 +254,18 @@ def create_app():
'get_motds': MOTD.get_all,
'get_time_now': lambda: int(time.time()),
'is_logged_in': is_logged_in,
'get_active_user': get_active_user,
'get_post_url': get_post_url,
}
@app.template_filter('ts_datetime')
def ts_datetime(ts, 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')
def pluralize(subject, num=1, singular = '', plural = 's'):
if int(num) == 1:

View File

@@ -1,6 +1,7 @@
from flask import session, flash
from .models import Sessions
from .models import Sessions, Users
from argon2 import PasswordHasher
import secrets
import time
ph = PasswordHasher()
@@ -15,9 +16,9 @@ def verify(expected, given):
return False
def is_logged_in():
if "pyrom_session_key" not in session:
if 'pyrom_session_key' not in session:
return False
sess = Sessions.find({"key": session["pyrom_session_key"]})
sess = Sessions.find({'key': session['pyrom_session_key']})
if not sess:
return False
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)
return False
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'
q = """
WITH latest_posts AS (
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,
u.username AS latest_post_username,
u.display_name AS latest_post_display_name,
ph.content AS latest_post_content,
posts.created_at AS latest_post_created_at,
posts.id AS latest_post_id
FROM
threads
JOIN users ON users.id = threads.user_id
JOIN (
thread_id,
id AS latest_post_id,
user_id AS latest_post_user_id,
created_at AS latest_post_created_at,
ROW_NUMBER() OVER (PARTITION BY thread_id ORDER BY created_at DESC) AS rn
FROM posts
),
post_counts AS (
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 DESC) 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 = ?
thread_id,
COUNT(*) AS posts_count
FROM posts
GROUP BY thread_id
)
SELECT
threads.title,
threads.slug,
threads.created_at,
threads.is_locked,
threads.is_stickied,
starter.username AS started_by,
starter.display_name AS started_by_display_name,
latest_poster.username AS latest_post_username,
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 ?'
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/')
@@ -12,6 +13,15 @@ def all_topics():
@bp.get('/<slug>')
def topic(slug):
t = Topics.find({'slug': slug})
if t:
return 'yes'
return 'no'
if not t:
return 'stub'
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">
<span>Pyrom commit <a href="{{ "https://git.poto.cafe/yagich/pyrom/commit/" + __commit }}">{{ __commit[:8] }}</a></span>
<ul class="horizontal">
{#-
<li><a href="#">Contact</a></li>
<li><a href="#">Guides</a></li>
-#}
<li><a href="{{url_for('guides.contact')}}">Contact</a></li>
<li><a href="{{url_for('guides.index')}}">Guides</a></li>
</ul>
</footer>

View File

@@ -1,3 +1,67 @@
{% 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>
{%- 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">
<a class="site-title" href=#>Porom</a>
<a class="site-title" href="/">Porom</a>
<span>anti-social media</span>
{%- 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 -%}
<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="text" placeholder="Username">
<input type="password" placeholder="Password">
<input type="text" placeholder="Username" name="username" autocomplete="username" required>
<input type="password" placeholder="Password" name="password" autocomplete="current-password" required>
<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>
{%- endif -%}
</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' -%}
{%- 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 -%}
<div class="topic-info plank">
<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()])