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
- 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
- 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
+ Blocking a user prevents them from seeing your comments, posts (standalone or in feed) and profile
- Added ✨color themes✨
- Users can now set their display name, biography and color theme in `/settings`

View file

@ -1,3 +1,44 @@
# 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
import warnings
from flask import (
Flask, g, render_template,
Flask, g, redirect, render_template,
request, send_from_directory, url_for
)
import os
@ -21,7 +21,7 @@ from werkzeug.middleware.proxy_fix import ProxyFix
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'
@ -131,8 +131,18 @@ def error_400(body):
def error_403(body):
return render_template('403.html'), 403
from .search import find_guild_or_user
@app.errorhandler(404)
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
@app.errorhandler(405)

View file

@ -8,7 +8,7 @@ AJAX hooks for the website.
import re
from flask import Blueprint, abort, flash, redirect, request
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
current_user: User
@ -18,7 +18,7 @@ bp = Blueprint('ajax', __name__)
@bp.route('/username_availability/<username>')
@bp.route('/ajax/username_availability/<username>')
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:
user = db.session.execute(select(User).where(User.username == username)).scalar()
@ -30,7 +30,7 @@ def username_availability(username: str):
return {
'status': 'ok',
'is_valid': is_valid,
'is_available': is_available,
'is_available': is_available
}
@bp.route('/guild_name_availability/<username>')
@ -47,7 +47,7 @@ def guild_name_availability(name: str):
return {
'status': 'ok',
'is_valid': is_valid,
'is_available': is_available,
'is_available': is_available
}
@bp.route('/comments/<b32l:id>/upvote', methods=['POST'])

View file

@ -6,6 +6,7 @@ from collections import namedtuple
import datetime
from functools import partial
from operator import or_
import re
from threading import Lock
from sqlalchemy import Column, Integer, String, ForeignKey, UniqueConstraint, text, \
CheckConstraint, Date, DateTime, Boolean, func, BigInteger, \
@ -62,6 +63,26 @@ REPORT_UPDATE_COMPLETE = 1
REPORT_UPDATE_REJECTED = 2
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
Base = declarative_base(app_config.domain_name, app_config.secret_key,
@ -259,6 +280,21 @@ class User(Base):
def strike_count(self) -> int:
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 !!
## END User

View file

@ -2,8 +2,11 @@
from typing import Iterable
from flask import flash, g
from sqlalchemy import Column, Select, select, or_
from .models import Guild, User, db
class SearchQuery:
keywords: Iterable[str]
@ -23,3 +26,25 @@ class SearchQuery:
sq = sq.where(or_(*or_cond) if len(or_cond) > 1 else or_cond[0])
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-border: #999
--light-success: #73af00
--light-error: #e04433
--light-error: #e04830
--light-warning: #dea800
--light-canvas: #eaecee
--light-background: #f9f9f9
--light-bg-sharp: #fdfdff
@ -35,7 +36,8 @@
--dark-text-alt: #c0cad3
--dark-border: #777
--dark-success: #93cf00
--dark-error: #e04433
--dark-error: #e04830
--dark-warning: #dea800
--dark-canvas: #0a0a0e
--dark-background: #181a21
--dark-bg-sharp: #080808
@ -51,6 +53,7 @@
--border: var(--light-border)
--success: var(--light-success)
--error: var(--light-error)
--warning: var(--light-warning)
--canvas: var(--light-canvas)
--background: var(--light-background)
--bg-sharp: var(--light-bg-sharp)
@ -62,6 +65,7 @@
--border: var(--dark-border)
--success: var(--dark-success)
--error: var(--dark-error)
--warning: var(--dark-warning)
--canvas: var(--dark-canvas)
--background: var(--dark-background)
--bg-sharp: var(--dark-bg-sharp)
@ -72,6 +76,7 @@
--border: var(--light-border)
--success: var(--light-success)
--error: var(--light-error)
--warning: var(--light-warning)
--canvas: var(--light-canvas)
--background: var(--light-background)
--bg-sharp: var(--light-bg-sharp)
@ -82,6 +87,7 @@
--border: var(--dark-border)
--success: var(--dark-success)
--error: var(--dark-error)
--warning: var(--dark-warning)
--canvas: var(--dark-canvas)
--background: var(--dark-background)
--bg-sharp: var(--dark-bg-sharp)
@ -186,3 +192,17 @@ img
.faint
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
border-right: 4px solid var(--border)
.callout
color: var(--text-alt)
.success
color: var(--success)
.error
color: var(--error)
.message-content
p
margin: 4px 0

View file

@ -378,3 +378,12 @@ button.card
margin: 12px auto
font-size: 36px
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/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 %}
{{ title_tag(feed_title) }}
{% endblock %}
@ -24,7 +20,7 @@
{{ nav_guild(guild) }}
{% endif %}
<aside class="card">
<aside class="card nomobile">
<h3>Dont miss a post!</h3>
<ul>
<li><strong><a id="notificationEnabler" href="#">Enable notifications</a></strong> to continue staying with us 😉</li>
@ -42,13 +38,11 @@
{% endfor %}
{% if l.has_next %}
{{ stop_scrolling(l.page) }}
{{ stop_scrolling(l.page) }}
{% else %}
{{ no_more_scrolling(l.page) }}
{% endif %}
</ul>
{# TODO: pagination #}
{% endblock %}
{% 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>
</aside>
{% 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)) }}
{% endif %}
{% 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>
{% call callout('spoiler', 'error') %}This action <u><b>cannot be undone</b></u>.{% endcall %}
{% 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 %}
</div>
<div>

View file

@ -15,11 +15,9 @@
<h2>Identification</h2>
<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 '' }}" />
</div>
<div><label for="US__biography">Bio:</label>
<textarea name="biography" id="US__biography">{{ current_user.biography or '' }}</textarea>
</div>
<div>
<button type="submit" class="primary">Save</button>

View file

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

View file

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