improved 404 handling, added mod tools page (stub), CSS .warning, outlawed some usernames

This commit is contained in:
Yusur 2025-07-08 01:01:50 +02:00
parent 793c0b6612
commit b0c815ea0a
17 changed files with 236 additions and 34 deletions

View file

@ -3,11 +3,12 @@
## 0.4.0 ## 0.4.0
- Added dependency to [SUOU](https://github.com/sakuragasaki46/suou) library - Added dependency to [SUOU](https://github.com/sakuragasaki46/suou) library
- Added user blocks - Users can now block each other
+ Blocking a user prevents them from seeing your comments, posts (standalone or in feed) and profile
- Added user strikes: a strike logs the content of a removed message for future use - Added user strikes: a strike logs the content of a removed message for future use
- Posts may now be deleted by author. If it has comments, comments are not spared - Posts may now be deleted by author. If it has comments, comments are not spared
- Moderators (and admins) have now access to mod tools
- Implemented guild subscriptions - Implemented guild subscriptions
+ Blocking a user prevents them from seeing your comments, posts (standalone or in feed) and profile
- Added ✨color themes✨ - Added ✨color themes✨
- Users can now set their display name, biography and color theme in `/settings` - Users can now set their display name, biography and color theme in `/settings`

View file

@ -1,3 +1,44 @@
# Freak # Freak
(´ω\`) > \~(´ω\`)\~
> (Josip Broz Tito, possibly)
**Freak** (as in extremely interested into something, NOT as in predator) is a in-development FOSS and sovereign alternative to Reddit (and an attempt to revive Ruqqus from scratch). The socio-moral reasons are beyond the scope of this README.
## Installation
* First make sure you have these requirements:
* Unix-like OS (Docker container, Linux or MacOS are all good).
* **Python** >=3.10. Recommended to use a virtualenv (unless in Docker lol).
* **PostgreSQL** at least 16.
* **Redis**/Valkey (as of 0.4.0 unused in codebase).
* A server machine with a public IP address and shell access (mandatory for production, optional for development/staging).
* A reverse proxy listening on ports 80 and 443. Reminder to set `APP_IS_BEHIND_PROXY=1` in `.env` !!!
* Electricity.
* Will to not give up.
* Clone this repository.
* Fill in `.env` with the necessary information.
* `DOMAIN_NAME` (you must own it. Don't have? `.xyz` are like $2 or $3 on Namecheap[^1])
* `APP_NAME`
* `DATABASE_URL` (hint: `postgresql://username:password@localhost/dbname`)
* `SECRET_KEY` (you can generate one with the command `cat /dev/random | tr -dc A-Za-z0-9_. | head -c 56`)
* `PRIVATE_ASSETS` (you must provide the icon stylesheets here. Useful for custom CSS / scripts as well)
* `APP_IS_BEHIND_PROXY` (mandatory if behind reverse proxy or NAT)
* ...
[^1]: Namecheap is an American company. Don't trust American companies.
## FAQ
...
## License
Licensed under the [Apache License, Version 2.0](LICENSE), a non-copyleft free and open source license.
This is a hobby project, made available “AS IS”, with __no warranty__ express or implied.
I (sakuragasaki46) may NOT be held accountable for Your use of my code.
> It's pointless to file a lawsuit because you feel damaged, and it's only going to turn against you. What a waste of money you could have spent on a vacation or charity, or invested in stocks.

View file

@ -5,7 +5,7 @@ from sqlite3 import ProgrammingError
from typing import Any from typing import Any
import warnings import warnings
from flask import ( from flask import (
Flask, g, render_template, Flask, g, redirect, render_template,
request, send_from_directory, url_for request, send_from_directory, url_for
) )
import os import os
@ -21,7 +21,7 @@ from werkzeug.middleware.proxy_fix import ProxyFix
from suou.configparse import ConfigOptions, ConfigValue from suou.configparse import ConfigOptions, ConfigValue
from freak.colors import color_themes, theme_classes from .colors import color_themes, theme_classes
__version__ = '0.4.0-dev27' __version__ = '0.4.0-dev27'
@ -131,8 +131,18 @@ def error_400(body):
def error_403(body): def error_403(body):
return render_template('403.html'), 403 return render_template('403.html'), 403
from .search import find_guild_or_user
@app.errorhandler(404) @app.errorhandler(404)
def error_404(body): def error_404(body):
try:
if mo := re.match(r'/([a-z0-9_-]+)/?', request.path):
alternative = find_guild_or_user(mo.group(1))
if alternative is not None:
return redirect(alternative), 302
except Exception as e:
warnings.warn(f'Exception in find_guild_or_user: {e}')
pass
return render_template('404.html'), 404 return render_template('404.html'), 404
@app.errorhandler(405) @app.errorhandler(405)

View file

@ -8,7 +8,7 @@ AJAX hooks for the website.
import re import re
from flask import Blueprint, abort, flash, redirect, request from flask import Blueprint, abort, flash, redirect, request
from sqlalchemy import delete, insert, select from sqlalchemy import delete, insert, select
from .models import Guild, Member, UserBlock, db, User, Post, PostUpvote from .models import Guild, Member, UserBlock, db, User, Post, PostUpvote, username_is_legal
from flask_login import current_user, login_required from flask_login import current_user, login_required
current_user: User current_user: User
@ -18,7 +18,7 @@ bp = Blueprint('ajax', __name__)
@bp.route('/username_availability/<username>') @bp.route('/username_availability/<username>')
@bp.route('/ajax/username_availability/<username>') @bp.route('/ajax/username_availability/<username>')
def username_availability(username: str): def username_availability(username: str):
is_valid = re.fullmatch('[a-z0-9_-]+', username) is not None is_valid = username_is_legal(username)
if is_valid: if is_valid:
user = db.session.execute(select(User).where(User.username == username)).scalar() user = db.session.execute(select(User).where(User.username == username)).scalar()
@ -30,7 +30,7 @@ def username_availability(username: str):
return { return {
'status': 'ok', 'status': 'ok',
'is_valid': is_valid, 'is_valid': is_valid,
'is_available': is_available, 'is_available': is_available
} }
@bp.route('/guild_name_availability/<username>') @bp.route('/guild_name_availability/<username>')
@ -47,7 +47,7 @@ def guild_name_availability(name: str):
return { return {
'status': 'ok', 'status': 'ok',
'is_valid': is_valid, 'is_valid': is_valid,
'is_available': is_available, 'is_available': is_available
} }
@bp.route('/comments/<b32l:id>/upvote', methods=['POST']) @bp.route('/comments/<b32l:id>/upvote', methods=['POST'])

View file

@ -6,6 +6,7 @@ from collections import namedtuple
import datetime import datetime
from functools import partial from functools import partial
from operator import or_ from operator import or_
import re
from threading import Lock from threading import Lock
from sqlalchemy import Column, Integer, String, ForeignKey, UniqueConstraint, text, \ from sqlalchemy import Column, Integer, String, ForeignKey, UniqueConstraint, text, \
CheckConstraint, Date, DateTime, Boolean, func, BigInteger, \ CheckConstraint, Date, DateTime, Boolean, func, BigInteger, \
@ -62,6 +63,26 @@ REPORT_UPDATE_COMPLETE = 1
REPORT_UPDATE_REJECTED = 2 REPORT_UPDATE_REJECTED = 2
REPORT_UPDATE_ON_HOLD = 3 REPORT_UPDATE_ON_HOLD = 3
USERNAME_RE = r'[a-z2-9_-][a-z0-9_-]+'
ILLEGAL_USERNAMES = (
## reserved for masspings and administrative claims
'me', 'everyone', 'here', 'admin', 'mod', 'modteam', 'moderator', 'sysop', 'room', 'all', 'any', 'nobody', 'deleted', 'suspended', 'owner', 'administrator', 'ai',
## law enforcement corps and slurs because yes
'pedo', 'rape', 'rapist', 'nigger', 'retard', 'ncmec', 'police', 'cops', '911', 'childsafety', 'report', 'dmca'
)
def username_is_legal(username: str) -> bool:
if len(username) < 2 or len(username) > 100:
return False
if re.fullmatch(USERNAME_RE, username) is None:
return False
if username in ILLEGAL_USERNAMES:
return False
return True
## END constants and enums ## END constants and enums
Base = declarative_base(app_config.domain_name, app_config.secret_key, Base = declarative_base(app_config.domain_name, app_config.secret_key,
@ -259,6 +280,21 @@ class User(Base):
def strike_count(self) -> int: def strike_count(self) -> int:
return db.session.execute(select(func.count('*')).select_from(UserStrike).where(UserStrike.user_id == self.id)).scalar() return db.session.execute(select(func.count('*')).select_from(UserStrike).where(UserStrike.user_id == self.id)).scalar()
def moderates(self, gu: Guild) -> bool:
## owner
if gu.owner_id == self.id:
return True
## admin or global mod
if self.is_administrator:
return True
memb = db.session.execute(select(Member).where(Member.user_id == self.id, Member.guild_id == gu.id)).scalar()
if memb is None:
return False
return memb.is_moderator
## TODO check banship?
# UserBlock table is at the top !! # UserBlock table is at the top !!
## END User ## END User

View file

@ -2,8 +2,11 @@
from typing import Iterable from typing import Iterable
from flask import flash, g
from sqlalchemy import Column, Select, select, or_ from sqlalchemy import Column, Select, select, or_
from .models import Guild, User, db
class SearchQuery: class SearchQuery:
keywords: Iterable[str] keywords: Iterable[str]
@ -23,3 +26,25 @@ class SearchQuery:
sq = sq.where(or_(*or_cond) if len(or_cond) > 1 else or_cond[0]) sq = sq.where(or_(*or_cond) if len(or_cond) > 1 else or_cond[0])
return sq return sq
def find_guild_or_user(name: str) -> str | None:
"""
Used in 404 error handler.
Returns an URL to redirect or None for no redirect.
"""
if hasattr(g, 'no_user'):
return None
gu = db.session.execute(select(Guild).where(Guild.name == name)).scalar()
if gu is not None:
flash(f'There is nothing at /{name}. Luckily, a guild with name {gu.handle()} happens to exist. Next time, remember to add + before!')
return gu.url()
user = db.session.execute(select(User).where(User.username == name)).scalar()
if user is not None:
flash(f'There is nothing at /{name}. Luckily, a user named {user.handle()} happens to exist. Next time, remember to add @ before!')
return user.url()
return None

View file

@ -26,7 +26,8 @@
--light-text-alt: #444 --light-text-alt: #444
--light-border: #999 --light-border: #999
--light-success: #73af00 --light-success: #73af00
--light-error: #e04433 --light-error: #e04830
--light-warning: #dea800
--light-canvas: #eaecee --light-canvas: #eaecee
--light-background: #f9f9f9 --light-background: #f9f9f9
--light-bg-sharp: #fdfdff --light-bg-sharp: #fdfdff
@ -35,7 +36,8 @@
--dark-text-alt: #c0cad3 --dark-text-alt: #c0cad3
--dark-border: #777 --dark-border: #777
--dark-success: #93cf00 --dark-success: #93cf00
--dark-error: #e04433 --dark-error: #e04830
--dark-warning: #dea800
--dark-canvas: #0a0a0e --dark-canvas: #0a0a0e
--dark-background: #181a21 --dark-background: #181a21
--dark-bg-sharp: #080808 --dark-bg-sharp: #080808
@ -51,6 +53,7 @@
--border: var(--light-border) --border: var(--light-border)
--success: var(--light-success) --success: var(--light-success)
--error: var(--light-error) --error: var(--light-error)
--warning: var(--light-warning)
--canvas: var(--light-canvas) --canvas: var(--light-canvas)
--background: var(--light-background) --background: var(--light-background)
--bg-sharp: var(--light-bg-sharp) --bg-sharp: var(--light-bg-sharp)
@ -62,6 +65,7 @@
--border: var(--dark-border) --border: var(--dark-border)
--success: var(--dark-success) --success: var(--dark-success)
--error: var(--dark-error) --error: var(--dark-error)
--warning: var(--dark-warning)
--canvas: var(--dark-canvas) --canvas: var(--dark-canvas)
--background: var(--dark-background) --background: var(--dark-background)
--bg-sharp: var(--dark-bg-sharp) --bg-sharp: var(--dark-bg-sharp)
@ -72,6 +76,7 @@
--border: var(--light-border) --border: var(--light-border)
--success: var(--light-success) --success: var(--light-success)
--error: var(--light-error) --error: var(--light-error)
--warning: var(--light-warning)
--canvas: var(--light-canvas) --canvas: var(--light-canvas)
--background: var(--light-background) --background: var(--light-background)
--bg-sharp: var(--light-bg-sharp) --bg-sharp: var(--light-bg-sharp)
@ -82,6 +87,7 @@
--border: var(--dark-border) --border: var(--dark-border)
--success: var(--dark-success) --success: var(--dark-success)
--error: var(--dark-error) --error: var(--dark-error)
--warning: var(--dark-warning)
--canvas: var(--dark-canvas) --canvas: var(--dark-canvas)
--background: var(--dark-background) --background: var(--dark-background)
--bg-sharp: var(--dark-bg-sharp) --bg-sharp: var(--dark-bg-sharp)
@ -186,3 +192,17 @@ img
.faint .faint
opacity: .75 opacity: .75
strong &
font-weight: 400
.callout
color: var(--text-alt)
.success
color: var(--success)
.error
color: var(--error)
.warning
color: var(--warning)

View file

@ -14,15 +14,6 @@ blockquote
padding-right: 1em padding-right: 1em
border-right: 4px solid var(--border) border-right: 4px solid var(--border)
.callout
color: var(--text-alt)
.success
color: var(--success)
.error
color: var(--error)
.message-content .message-content
p p
margin: 4px 0 margin: 4px 0

View file

@ -378,3 +378,12 @@ button.card
margin: 12px auto margin: 12px auto
font-size: 36px font-size: 36px
text-align: center text-align: center
textarea.create_text
min-height: 8em
// specificity ew //
form.boundaryless &
min-height: 8em

View file

@ -3,10 +3,6 @@
{% from "macros/title.html" import title_tag with context %} {% from "macros/title.html" import title_tag with context %}
{% from "macros/nav.html" import nav_guild, nav_top_communities with context %} {% from "macros/nav.html" import nav_guild, nav_top_communities with context %}
{# set feed_title = 'For you' if feed_type == 'foryou' and not feed_title %}
{% set feed_title = 'Explore' if feed_type == 'explore' and not feed_title #}
{% block title %} {% block title %}
{{ title_tag(feed_title) }} {{ title_tag(feed_title) }}
{% endblock %} {% endblock %}
@ -24,7 +20,7 @@
{{ nav_guild(guild) }} {{ nav_guild(guild) }}
{% endif %} {% endif %}
<aside class="card"> <aside class="card nomobile">
<h3>Dont miss a post!</h3> <h3>Dont miss a post!</h3>
<ul> <ul>
<li><strong><a id="notificationEnabler" href="#">Enable notifications</a></strong> to continue staying with us 😉</li> <li><strong><a id="notificationEnabler" href="#">Enable notifications</a></strong> to continue staying with us 😉</li>
@ -47,8 +43,6 @@
{{ no_more_scrolling(l.page) }} {{ no_more_scrolling(l.page) }}
{% endif %} {% endif %}
</ul> </ul>
{# TODO: pagination #}
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}

View file

@ -0,0 +1,29 @@
{% extends "base.html" %}
{% from "macros/title.html" import title_tag with context %}
{% from "macros/create.html" import checked_if with context %}
{% block title %}{{ title_tag('Settings for ' + gu.handle()) }}{% endblock %}
{% block heading %}
<h1><span class="faint">Settings:</span> {{ gu.handle() }}</h1>
{% endblock %}
{% block content %}
<form method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
<section class="card">
<h2>Community Identity</h2>
<div>
<label for="GS__display_name">Display name:</label>
<input type="text" name="display_name" id="GS__display_name" value="{{ gu.display_name or '' }}" />
</div>
<div>
<label for="GS__description">Description:</label>
<textarea name="description" id="GS__description">{{ gu.description or '' }}</textarea>
</div>
<div>
<button type="submit" class="primary">Save</button>
</div>
</section>
</form>
{% endblock %}

View file

@ -14,6 +14,9 @@
</ul> </ul>
</aside> </aside>
{% if current_user.is_authenticated %} {% if current_user.is_authenticated %}
{% if current_user.moderates(gu) %}
<a href="{{ gu.url() }}/settings"><button class="card">{{ icon('settings') }} Mod Tools</button></a>
{% endif %}
{{ subscribe_button(gu, gu.has_subscriber(current_user)) }} {{ subscribe_button(gu, gu.has_subscriber(current_user)) }}
{% endif %} {% endif %}
{% endmacro %} {% endmacro %}

View file

@ -16,7 +16,7 @@
<p>You are about to delete <u>permanently</u> <a href="{{ p.url() }}">your post on {{ p.topic_or_user().handle() }}</a>.</p> <p>You are about to delete <u>permanently</u> <a href="{{ p.url() }}">your post on {{ p.topic_or_user().handle() }}</a>.</p>
{% call callout('spoiler', 'error') %}This action <u><b>cannot be undone</b></u>.{% endcall %} {% call callout('spoiler', 'error') %}This action <u><b>cannot be undone</b></u>.{% endcall %}
{% if (p.comments | count) %} {% if (p.comments | count) %}
{% call callout('spoiler') %}Your post has <strong>{{ (p.comments | count) }} comments</strong>. Your post will be deleted <u>along with ALL the comments</u>.{% endcall %} {% call callout('spoiler', 'warning') %}Your post has <strong>{{ (p.comments | count) }} comments</strong>. Your post will be deleted <u>along with ALL the comments</u>.{% endcall %}
{% endif %} {% endif %}
</div> </div>
<div> <div>

View file

@ -15,11 +15,9 @@
<h2>Identification</h2> <h2>Identification</h2>
<div><label for="US__display_name">Full name:</label> <div><label for="US__display_name">Full name:</label>
<input type="text" name="display_name" id="US__display_name" value="{{ current_user.display_name or '' }}" /> <input type="text" name="display_name" id="US__display_name" value="{{ current_user.display_name or '' }}" />
</div> </div>
<div><label for="US__biography">Bio:</label> <div><label for="US__biography">Bio:</label>
<textarea name="biography" id="US__biography">{{ current_user.biography or '' }}</textarea> <textarea name="biography" id="US__biography">{{ current_user.biography or '' }}</textarea>
</div> </div>
<div> <div>
<button type="submit" class="primary">Save</button> <button type="submit" class="primary">Save</button>

View file

@ -20,6 +20,9 @@ blueprints.append(bp)
from .delete import bp from .delete import bp
blueprints.append(bp) blueprints.append(bp)
from .moderation import bp
blueprints.append(bp)
from .about import bp from .about import bp
blueprints.append(bp) blueprints.append(bp)

View file

@ -1,3 +1,6 @@
from __future__ import annotations
import os, sys import os, sys
import re import re
import datetime import datetime
@ -10,6 +13,8 @@ from ..utils import age_and_days
from sqlalchemy import select, insert from sqlalchemy import select, insert
from werkzeug.security import generate_password_hash from werkzeug.security import generate_password_hash
current_user: User
bp = Blueprint('accounts', __name__) bp = Blueprint('accounts', __name__)
@bp.route('/login', methods=['GET', 'POST']) @bp.route('/login', methods=['GET', 'POST'])
@ -112,12 +117,13 @@ COLOR_SCHEMES = {'dark': 2, 'light': 1, 'system': 0, 'unset': 0}
@login_required @login_required
def settings(): def settings():
if request.method == 'POST': if request.method == 'POST':
user: User = current_user changes = False
user = current_user
color_scheme = COLOR_SCHEMES[request.form.get('color_scheme')] if 'color_scheme' in request.form else None color_scheme = COLOR_SCHEMES[request.form.get('color_scheme')] if 'color_scheme' in request.form else None
color_theme = int(request.form.get('color_theme')) if 'color_theme' in request.form else None color_theme = int(request.form.get('color_theme')) if 'color_theme' in request.form else None
biography = request.form.get('biography') biography = request.form.get('biography')
display_name = request.form.get('display_name') display_name = request.form.get('display_name')
changes = False
if display_name and display_name != user.display_name: if display_name and display_name != user.display_name:
changes, user.display_name = True, display_name.strip() changes, user.display_name = True, display_name.strip()
if biography and biography != user.biography: if biography and biography != user.biography:

View file

@ -0,0 +1,36 @@
from flask import Blueprint, abort, flash, render_template, request
from flask_login import current_user, login_required
from sqlalchemy import select
from ..models import db, User, Guild
current_user: User
bp = Blueprint('moderation', __name__)
@bp.route('/+<name>/settings', methods=['GET', 'POST'])
@login_required
def guild_settings(name: str):
gu = db.session.execute(select(Guild).where(Guild.name == name)).scalar()
if not current_user.moderates(gu):
abort(403)
if request.method == 'POST':
changes = False
display_name = request.form.get('display_name')
description = request.form.get('description')
if description and description != gu.description:
changes, gu.description = True, description.strip()
if display_name and display_name != gu.display_name:
changes, gu.display_name = True, display_name.strip()
if changes:
db.session.add(gu)
db.session.commit()
flash('Changes saved!')
return render_template('guildsettings.html', gu=gu)