0.3.0: initial commit + Dockerfile + rewrite
This commit is contained in:
commit
e679de5991
77 changed files with 4147 additions and 0 deletions
27
freak/website/__init__.py
Normal file
27
freak/website/__init__.py
Normal 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
29
freak/website/about.py
Normal 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
103
freak/website/accounts.py
Normal 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
79
freak/website/admin.py
Normal 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
74
freak/website/create.py
Normal 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
100
freak/website/detail.py
Normal 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
36
freak/website/edit.py
Normal 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)
|
||||
|
||||
62
freak/website/frontpage.py
Normal file
62
freak/website/frontpage.py
Normal 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
56
freak/website/reports.py
Normal 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)
|
||||
|
||||
Loading…
Add table
Add a link
Reference in a new issue