0.3.0: initial commit + Dockerfile + rewrite

This commit is contained in:
Yusur 2025-06-13 03:01:32 +02:00
commit e679de5991
77 changed files with 4147 additions and 0 deletions

27
freak/website/__init__.py Normal file
View file

@ -0,0 +1,27 @@
blueprints = []
from .frontpage import bp
blueprints.append(bp)
from .accounts import bp
blueprints.append(bp)
from .detail import bp
blueprints.append(bp)
from .create import bp
blueprints.append(bp)
from .edit import bp
blueprints.append(bp)
from .about import bp
blueprints.append(bp)
from .reports import bp
blueprints.append(bp)
from .admin import bp
blueprints.append(bp)

29
freak/website/about.py Normal file
View file

@ -0,0 +1,29 @@
import sys
from flask import Blueprint, render_template, __version__ as flask_version
from sqlalchemy import __version__ as sa_version
from .. import __version__ as app_version
bp = Blueprint('about', __name__)
@bp.route('/about/')
def about():
return render_template('about.html',
flask_version=flask_version,
sa_version=sa_version,
python_version=sys.version.split()[0],
app_version=app_version
)
@bp.route('/terms/')
def terms():
return render_template('terms.html')
@bp.route('/privacy/')
def privacy():
return render_template('privacy.html')
@bp.route('/rules/')
def rules():
return render_template('rules.html')

103
freak/website/accounts.py Normal file
View file

@ -0,0 +1,103 @@
import os, sys
import re
import datetime
from typing import Mapping
from flask import Blueprint, render_template, request, redirect, flash
from flask_login import login_user, logout_user, current_user
from ..models import REPORT_REASONS, db, User
from ..utils import age_and_days
from sqlalchemy import select, insert
from werkzeug.security import generate_password_hash
bp = Blueprint('accounts', __name__)
@bp.route('/login', methods=['GET', 'POST'])
def login():
if request.method == 'POST' and request.form['username']:
username = request.form['username']
password = request.form['password']
if '@' in username:
user = db.session.execute(select(User).where(User.email == username)).scalar()
else:
user = db.session.execute(select(User).where(User.username == username)).scalar()
if user and '$' not in user.passhash:
flash('You need to reset your password following the procedure.')
return render_template('login.html')
elif not user or not user.check_password(password):
flash('Invalid username or password')
return render_template('login.html')
elif not user.is_active:
flash('Your account is suspended')
else:
remember_for = int(request.form.get('remember', 0))
if remember_for > 0:
login_user(user, remember=True, duration=datetime.timedelta(days=remember_for))
else:
login_user(user)
return redirect(request.args.get('next', '/'))
return render_template('login.html')
@bp.route('/logout')
def logout():
logout_user()
flash('Logged out. Come back soon~')
return redirect(request.args.get('next','/'))
## XXX temp
def _currently_logged_in() -> bool:
return current_user and current_user.is_authenticated
def validate_register_form() -> dict:
f = dict()
try:
f['gdpr_birthday'] = datetime.date.fromisoformat(request.form['birthday'])
if age_and_days(f['gdpr_birthday']) < (14,):
f['banned_at'] = datetime.datetime.now()
f['banned_reason'] = REPORT_REASONS['underage']
except ValueError:
raise ValueError('Invalid date format')
f['username'] = request.form['username'].lower()
if not re.fullmatch('[a-z0-9_-]+', f['username']):
raise ValueError('Username can contain only letters, digits, underscores and dashes.')
f['display_name'] = request.form.get('full_name')
if request.form['password'] != request.form['confirm_password']:
raise ValueError('Passwords do not match.')
f['passhash'] = generate_password_hash(request.form['password'])
f['email'] = request.form['email'] or None,
if _currently_logged_in() and not request.form.get('confirm_another'):
raise ValueError('You are already logged in. Please confirm you want to create another account by checking the option.')
if not request.form.get('legal'):
raise ValueError('You must accept Terms in order to create an account.')
return f
@bp.route('/register', methods=['GET', 'POST'])
def register():
if request.method == 'POST' and request.form['username']:
try:
user_data = validate_register_form()
except ValueError as e:
if e.args:
flash(e.args[0])
return render_template('register.html')
try:
db.session.execute(insert(User).values(**user_data))
db.session.commit()
flash('Account created successfully. You can now log in.')
return redirect(request.args.get('next', '/'))
except Exception as e:
sys.excepthook(*sys.exc_info())
flash('Unable to create account (possibly your username is already taken)')
return render_template('register.html')
return render_template('register.html')

79
freak/website/admin.py Normal file
View file

@ -0,0 +1,79 @@
import datetime
from functools import wraps
from typing import Callable
from flask import Blueprint, abort, redirect, render_template, request, url_for
from flask_login import current_user
from sqlalchemy import select, update
from ..models import REPORT_REASON_STRINGS, REPORT_UPDATE_COMPLETE, REPORT_UPDATE_ON_HOLD, REPORT_UPDATE_REJECTED, Comment, Post, PostReport, User, db
bp = Blueprint('admin', __name__)
## TODO make admin interface
def admin_required(func: Callable):
@wraps(func)
def wrapper(**ka):
user: User = current_user
if not user.is_authenticated or not user.is_administrator:
abort(403)
return func(**ka)
return wrapper
def accept_report(target, source: PostReport):
if isinstance(target, Post):
target.removed_at = datetime.datetime.now()
target.removed_by_id = current_user.id
target.removed_reason = source.reason_code
elif isinstance(target, Comment):
target.removed_at = datetime.datetime.now()
target.removed_by_id = current_user.id
target.removed_reason = source.reason_code
db.session.add(target)
source.update_status = REPORT_UPDATE_COMPLETE
db.session.add(source)
db.session.commit()
def reject_report(target, source: PostReport):
source.update_status = REPORT_UPDATE_REJECTED
db.session.add(source)
db.session.commit()
def withhold_report(target, source: PostReport):
source.update_status = REPORT_UPDATE_ON_HOLD
db.session.add(source)
db.session.commit()
REPORT_ACTIONS = {
'1': accept_report,
'0': reject_report,
'2': withhold_report
}
@bp.route('/admin/')
@admin_required
def homepage():
return render_template('admin/admin_home.html')
@bp.route('/admin/reports/')
@admin_required
def reports():
report_list = db.paginate(select(PostReport).order_by(PostReport.id.desc()))
return render_template('admin/admin_reports.html',
report_list=report_list, report_reasons=REPORT_REASON_STRINGS)
@bp.route('/admin/reports/<b32l:id>', methods=['GET', 'POST'])
@admin_required
def report_detail(id: int):
report = db.session.execute(select(PostReport).where(PostReport.id == id)).scalar()
if report is None:
abort(404)
if request.method == 'POST':
action = REPORT_ACTIONS[request.form['do']]
action(report.target(), report)
return redirect(url_for('admin.reports'))
return render_template('admin/admin_report_detail.html', report=report,
report_reasons=REPORT_REASON_STRINGS)

74
freak/website/create.py Normal file
View file

@ -0,0 +1,74 @@
import sys
import datetime
from flask import Blueprint, abort, redirect, flash, render_template, request, url_for
from flask_login import current_user, login_required
from sqlalchemy import insert
from ..models import User, db, Topic, Post
bp = Blueprint('create', __name__)
@bp.route('/create/', methods=['GET', 'POST'])
@login_required
def create():
user: User = current_user
if request.method == 'POST' and 'title' in request.form:
topic_name = request.form['to']
if topic_name:
topic: Topic | None = db.session.execute(db.select(Topic).where(Topic.name == topic_name)).scalar()
if topic is None:
flash(f'Topic +{topic_name} not found, posting to your user page instead')
else:
topic = None
title = request.form['title']
text = request.form['text']
privacy = int(request.form.get('privacy', '0'))
try:
new_post: Post = db.session.execute(insert(Post).values(
author_id = user.id,
topic_id = topic.id if topic else None,
created_at = datetime.datetime.now(),
privacy = privacy,
title = title,
text_content = text
).returning(Post.id)).fetchone()
db.session.commit()
flash(f'Published on {'+' + topic_name if topic_name else '@' + user.username}')
return redirect(url_for('detail.post_detail', id=new_post.id))
except Exception as e:
sys.excepthook(*sys.exc_info())
flash('Unable to publish!')
return render_template('create.html')
@bp.route('/createguild/', methods=['GET', 'POST'])
@login_required
def createguild():
if request.method == 'POST':
user: User = current_user
if not user.can_create_community():
flash('You are NOT allowed to create new guilds.')
abort(403)
c_name = request.form['name']
try:
c_id = db.session.execute(db.insert(Topic).values(
name = c_name,
display_name = request.form.get('display_name', c_name),
description = request.form['description'],
owner_id = user.id
).returning(Topic.id)).fetchone()
db.session.commit()
return redirect(url_for('frontpage.topic_feed', name=c_name))
except Exception:
sys.excepthook(*sys.exc_info())
flash('Unable to create guild. It may already exist or you could not have permission to create new communities.')
return render_template('createguild.html')
@bp.route('/createcommunity/')
def createcommunity_redirect():
return redirect(url_for('create.createguild')), 301

100
freak/website/detail.py Normal file
View file

@ -0,0 +1,100 @@
from flask import Blueprint, abort, flash, request, redirect, render_template, url_for
from flask_login import current_user, login_required
from sqlalchemy import select
from ..iding import id_from_b32l
from ..utils import is_b32l
from ..models import Comment, db, User, Post, Topic
from ..algorithms import user_timeline
bp = Blueprint('detail', __name__)
@bp.route('/@<username>')
def user_profile(username):
user = db.session.execute(select(User).where(User.username == username)).scalar()
if user is None:
abort(404)
posts = user_timeline(user.id)
return render_template('userfeed.html', l=db.paginate(posts), user=user)
@bp.route('/u/<username>')
@bp.route('/user/<username>')
def user_profile_u(username: str):
if is_b32l(username):
userid = id_from_b32l(username)
user = db.session.execute(select(User).where(User.id == userid)).scalar()
if user is not None:
username = user.username
return redirect('/@' + username), 302
@bp.route('/@<username>/')
def user_profile_s(username):
return redirect('/@' + username), 301
def single_post_post_hook(p: Post):
if 'reply_to' in request.form:
reply_to_id = request.form['reply_to']
text = request.form['text']
reply_to_p = db.session.execute(db.select(Post).where(Post.id == id_from_b32l(reply_to_id))).scalar() if reply_to_id else None
db.session.execute(db.insert(Comment).values(
author_id = current_user.id,
parent_post_id = p.id,
parent_comment_id = reply_to_p,
text_content = text
))
db.session.commit()
flash('Comment published')
return redirect(p.url()), 303
abort(501)
@bp.route('/comments/<b32l:id>')
def post_detail(id: int):
post: Post | None = db.session.execute(db.select(Post).where(Post.id == id)).scalar()
if post and post.url() != request.full_path:
return redirect(post.url()), 302
else:
abort(404)
@bp.route('/@<username>/comments/<b32l:id>/', methods=['GET', 'POST'])
@bp.route('/@<username>/comments/<b32l:id>/<slug:slug>', methods=['GET', 'POST'])
def user_post_detail(username: str, id: int, slug: str = ''):
post: Post | None = db.session.execute(select(Post).join(User, User.id == Post.author_id).where(Post.id == id, User.username == username)).scalar()
if post is None or (post.is_removed and post.author != current_user):
abort(404)
if post.slug and not slug:
return redirect(url_for('detail.user_post_detail_slug', username=username, id=id, slug=post.slug)), 302
if request.method == 'POST':
single_post_post_hook(post)
return render_template('singlepost.html', p=post)
@bp.route('/+<topicname>/comments/<b32l:id>/', methods=['GET', 'POST'])
@bp.route('/+<topicname>/comments/<b32l:id>/<slug:slug>', methods=['GET', 'POST'])
def topic_post_detail(topicname, id, slug=''):
post: Post | None = db.session.execute(select(Post).join(Topic).where(Post.id == id, Topic.name == topicname)).scalar()
if post is None or (post.is_removed and post.author != current_user):
abort(404)
if post.slug and not slug:
return redirect(url_for('detail.topic_post_detail_slug', topicname=topicname, id=id, slug=post.slug)), 302
if request.method == 'POST':
single_post_post_hook(post)
return render_template('singlepost.html', p=post)

36
freak/website/edit.py Normal file
View file

@ -0,0 +1,36 @@
import datetime
from flask import Blueprint, abort, flash, redirect, render_template, request
from flask_login import current_user, login_required
from ..models import Post, db
bp = Blueprint('edit', __name__)
@bp.route('/edit/post/<b32l:id>', methods=['GET', 'POST'])
@login_required
def edit_post(id):
p: Post | None = db.session.execute(db.select(Post).where(Post.id == id)).scalar()
if p is None:
abort(404)
if current_user.id != p.author.id:
abort(403)
if request.method == 'POST':
text = request.form['text']
privacy = int(request.form.get('privacy', '0'))
db.session.execute(db.update(Post).where(Post.id == id).values(
text_content = text,
privacy = privacy,
updated_at = datetime.datetime.now()
))
db.session.commit()
flash('Your changes have been saved')
return redirect(p.url()), 303
return render_template('edit.html', p=p)

View file

@ -0,0 +1,62 @@
from flask import Blueprint, render_template, redirect, abort, request
from flask_login import current_user
from ..search import SearchQuery
from ..models import Post, db, Topic
from ..algorithms import public_timeline, top_guilds_query, topic_timeline
bp = Blueprint('frontpage', __name__)
@bp.route('/')
def homepage():
top_communities = [(x[0], x[1], 0) for x in
db.session.execute(top_guilds_query().limit(10)).fetchall()]
if current_user and current_user.is_authenticated:
# renders user's own timeline
# TODO this is currently the public timeline.
return render_template('feed.html', feed_type='foryou', l=db.paginate(public_timeline()),
top_communities=top_communities)
else:
# Show a landing page to anonymous users.
return render_template('landing.html', top_communities=top_communities)
@bp.route('/explore/')
def explore():
return render_template('feed.html', feed_type='explore', l=db.paginate(public_timeline()))
@bp.route('/+<name>/')
def topic_feed(name):
topic: Topic = db.session.execute(db.select(Topic).where(Topic.name == name)).scalar()
if topic is None:
abort(404)
posts = db.paginate(topic_timeline(name))
return render_template(
'feed.html', feed_type='topic', feed_title=f'{topic.display_name} (+{topic.name})', l=posts, topic=topic)
@bp.route('/r/<name>/')
def topic_feed_r(name):
return redirect('/+' + name + '/'), 302
@bp.route("/search", methods=["GET", "POST"])
def search():
if request.method == "POST":
q = request.form["q"]
if q:
results = db.paginate(SearchQuery(q).select(Post, [Post.title]).order_by(Post.created_at.desc()))
else:
results = None
return render_template(
"search.html",
results=results,
q = q
)
return render_template("search.html")

56
freak/website/reports.py Normal file
View file

@ -0,0 +1,56 @@
from flask import Blueprint, render_template, request
from flask_login import current_user, login_required
from ..models import REPORT_TARGET_COMMENT, REPORT_TARGET_POST, ReportReason, post_report_reasons, Comment, Post, PostReport, REPORT_REASONS, db
bp = Blueprint('reports', __name__)
def description_text(rlist: list[ReportReason], key: str) -> str:
results = [x.description for x in rlist if x.code == key]
return results[0] if results else key
@bp.route('/report/post/<b32l:id>', methods=['GET', 'POST'])
@login_required
def report_post(id: int):
p: Post | None = db.session.execute(db.select(Post).where(Post.id == id)).scalar()
if p is None:
return render_template('reports/report_404.html', target_type = 1), 404
if p.author_id == current_user.id:
return render_template('reports/report_self.html', back_to_url=p.url()), 403
if request.method == 'POST':
reason = request.args['reason']
db.session.execute(db.insert(PostReport).values(
author_id = current_user.id,
target_type = REPORT_TARGET_POST,
target_id = id,
reason_code = REPORT_REASONS[reason]
))
db.session.commit()
return render_template('reports/report_done.html', back_to_url=p.url())
return render_template('reports/report_post.html', id = id,
report_reasons = post_report_reasons, description_text=description_text)
@bp.route('/report/comment/<b32l:id>', methods=['GET', 'POST'])
@login_required
def report_comment(id: int):
c: Comment | None = db.session.execute(db.select(Comment).where(Comment.id == id)).scalar()
if c is None:
return render_template('reports/report_404.html', target_type = 2), 404
if c.author_id == current_user.id:
return render_template('reports/report_self.html', back_to_url=c.parent_post.url()), 403
if request.method == 'POST':
reason = request.args['reason']
db.session.execute(db.insert(PostReport).values(
author_id = current_user.id,
target_type = REPORT_TARGET_COMMENT,
target_id = id,
reason_code = REPORT_REASONS[reason]
))
db.session.commit()
return render_template('reports/report_done.html',
back_to_url=c.parent_post.url())
return render_template('reports/report_comment.html', id = id,
report_reasons = post_report_reasons, description_text=description_text)