add impressum, update README, add abandoned guild claim and adding moderators

This commit is contained in:
Yusur 2025-07-16 16:35:22 +02:00
parent f97e613f7a
commit b821f39bbf
9 changed files with 158 additions and 13 deletions

View file

@ -9,9 +9,12 @@
- 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 - Moderators (and admins) have now access to mod tools
+ Allowed operations: change display name, description, restriction status, and exile (guild-local ban) members + Allowed operations: change display name, description, restriction status, and exile (guild-local ban) members
- Implemented guild subscriptions + Site administrators and guild owners can add moderators
- Administrators can claim ownership of abandoned guilds
- Implemented guild subscriptions (not as in $$$, yes as in the follow button)
- 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`
- You can now add an impressum in .env, e.g. `IMPRESSUM='Acme Ltd.::1 Short Island::Old York, Divided States::Responsible: ::Donald Duck'` Lines are separated by two colons. Version before 0.4.0 CAN'T BE RUN in German-speaking countries as of 2025.
## 0.3.3 ## 0.3.3

View file

@ -11,26 +11,73 @@
* Unix-like OS (Docker container, Linux or MacOS are all good). * Unix-like OS (Docker container, Linux or MacOS are all good).
* **Python** >=3.10. Recommended to use a virtualenv (unless in Docker lol). * **Python** >=3.10. Recommended to use a virtualenv (unless in Docker lol).
* **PostgreSQL** at least 16. * **PostgreSQL** at least 16.
* **Redis**/Valkey (as of 0.4.0 unused in codebase). * **Redis**/Valkey (as of 0.4.0 unused in codebase -_-).
* **Docker** and **Docker Compose**.
* A server machine with a public IP address and shell access (mandatory for production, optional for development/staging). * 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` !!! * First time? I recommend a VPS. The cheapest one starts at €5/month, half a Spotify subscription.
* You must have **shell access**. FTP only is not enough.
* A domain (mandatory for production).
* You must have bought it beforehand. Don't have? `.xyz` are like $2 or $3 on Namecheap[^1]
* For development, tweaking `/etc/hosts` or plain running on `localhost:5000` is usually enough.
* A reverse proxy (i.e. Caddy or nginx) listening on ports 80 and 443. Reminder to set `APP_IS_BEHIND_PROXY=1` in `.env` !!!
* Electricity. * Electricity.
* Will to not give up. * Will to not give up.
* Clone this repository. * Clone this repository.
* Fill in `.env` with the necessary information. * 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]) * `DOMAIN_NAME` (see above)
* `APP_NAME` * `APP_NAME`
* `DATABASE_URL` (hint: `postgresql://username:password@localhost/dbname`) * `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`) * `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) * `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) * `APP_IS_BEHIND_PROXY` (mandatory if behind reverse proxy or NAT)
* ... * `IMPRESSUM` (if you host or serve your site in Germany[^2]. Lines are separated by double colons `::`)
* Adjust `docker-compose.yml` to your liking.
* Run `docker compose build`.
* Create a systemd unit file looking like this:
```systemd
[Unit]
Description=Freak
## using Caddy? replace nginx.service with Caddy.service. Yes, twice
Wants=nginx.service docker.service
After=nginx.service docker.service
[Service]
Type=simple
## REPLACE it with your path
WorkingDirectory=/path/to/repository/freak
ExecStart=/usr/bin/docker compose up
ExecReload=/usr/bin/docker compose run freak bash ./docker-run.sh r
ExecStop=/usr/bin/docker compose down
[Install]
WantedBy=multi-user.target
```
* Copy the file to `/usr/lib/systemd/system` (with root access)
* Run `sudo systemctl enable --now freak.service`
* Expect no red text or weird error gibberish. If there is, you did not follow the tutorial: read it from the start again.
* Congratulations! Your Freak instance is up and running
[^1]: Namecheap is an American company. Don't trust American companies. [^1]: Namecheap is an American company. Don't trust American companies.
[^2]: Not legal advice.
## FAQ ## FAQ
... ### Why another Reddit clone?
I felt like it.
### Will Freak be federated?
It's on the roadmap. However, it probably won't be fully functional if not after at least twenty feature releases. Therefore, wait patiently.
Freak is currently implementing the [SIS](https://yusur.moe/protocols/sis.html).
### What is your legal contact / Impressum?
You have to configure it yourself by setting `IMPRESSUM` in `.env`.
I only write the code. I am not accountable for Your use (see [License](#license)).
## License ## License

View file

@ -0,0 +1,14 @@
services:
freak:
build:
context: .
image: freak
ports:
- 5000:5000
volumes:
- .:/opt/live-app:ro
extra_hosts:
- 'postgres.docker.internal:172.17.0.1'
restart: on-failure:3

View file

@ -22,6 +22,7 @@ from werkzeug.middleware.proxy_fix import ProxyFix
from suou.configparse import ConfigOptions, ConfigValue from suou.configparse import ConfigOptions, ConfigValue
from .colors import color_themes, theme_classes from .colors import color_themes, theme_classes
from .utils import twocolon_list
__version__ = '0.4.0-dev28' __version__ = '0.4.0-dev28'
@ -38,6 +39,7 @@ class AppConfig(ConfigOptions):
private_assets = ConfigValue(cast=ssv_list) private_assets = ConfigValue(cast=ssv_list)
jquery_url = ConfigValue(default='https://ajax.googleapis.com/ajax/libs/jquery/3.7.1/jquery.min.js') jquery_url = ConfigValue(default='https://ajax.googleapis.com/ajax/libs/jquery/3.7.1/jquery.min.js')
app_is_behind_proxy = ConfigValue(cast=bool, default=False) app_is_behind_proxy = ConfigValue(cast=bool, default=False)
impressum = ConfigValue(cast=twocolon_list, default=None)
app_config = AppConfig() app_config = AppConfig()
@ -97,7 +99,8 @@ def _inject_variables():
'post_count': Post.count(), 'post_count': Post.count(),
'user_count': User.active_count(), 'user_count': User.active_count(),
'colors': color_themes, 'colors': color_themes,
'theme_classes': theme_classes 'theme_classes': theme_classes,
'impressum': '\n'.join(app_config.impressum).replace('_', ' ')
} }
@login_manager.user_loader @login_manager.user_loader

View file

@ -360,18 +360,21 @@ class Guild(Base):
if self.owner: if self.owner:
yield ModeratorInfo(self.owner, True) yield ModeratorInfo(self.owner, True)
for mem in db.session.execute(select(Member).where(Member.guild_id == self.id, Member.is_moderator == True)).scalars(): for mem in db.session.execute(select(Member).where(Member.guild_id == self.id, Member.is_moderator == True)).scalars():
if mem.user != self.owner and not mem.user.is_banned: if mem.user != self.owner and not mem.is_banned:
yield ModeratorInfo(mem.user, False) yield ModeratorInfo(mem.user, False)
def update_member(self, u: User | Member, /, **values): def update_member(self, u: User | Member, /, **values):
if isinstance(u, User): if isinstance(u, User):
m = db.session.execute(select(Member).where(Member.user_id == u.id, Member.guild_id == self.id)).scalar() m = db.session.execute(select(Member).where(Member.user_id == u.id, Member.guild_id == self.id)).scalar()
if m is None: if m is None:
return db.session.execute(insert(Member).values( m = db.session.execute(insert(Member).values(
guild_id = self.id, guild_id = self.id,
user_id = u.id, user_id = u.id,
**values **values
).returning(Member)).scalar() ).returning(Member)).scalar()
if m is None:
raise RuntimeError
return m
else: else:
m = u m = u
if len(values): if len(values):

View file

@ -24,7 +24,13 @@
</ul> </ul>
<h2>License</h2> <h2>License</h2>
<p>Source code is available at: <a href="https://github.com/sakuragasaki46/freak">https://github.com/sakuragasaki46/freak</a></p> <p>Source code is available at: <a href="https://github.com/yusurko/freak">https://github.com/yusurko/freak</a></p>
{% if impressum %}
<h2>Legal Contacts</h2>
<pre>{{ impressum }}</pre>
{% endif %}
</div> </div>
{% endblock %} {% endblock %}

View file

@ -1,14 +1,30 @@
{% extends "base.html" %} {% extends "base.html" %}
{% from "macros/icon.html" import icon with context %}
{% from "macros/title.html" import title_tag with context %} {% from "macros/title.html" import title_tag with context %}
{% from "macros/create.html" import checked_if with context %} {% from "macros/create.html" import checked_if with context %}
{% block title %}{{ title_tag('Settings for ' + gu.handle()) }}{% endblock %} {% block title %}{{ title_tag('Settings for ' + gu.handle()) }}{% endblock %}
{% block heading %} {% block heading %}
<h1><span class="faint">Settings:</span> {{ gu.handle() }}</h1> <h1><span class="faint">Settings:</span> <a href="{{ gu.url() }}">{{ gu.handle() }}</a></h1>
{% endblock %} {% endblock %}
{% block content %} {% block content %}
{% if gu.owner == None and current_user.is_administrator %}
<form method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
<input type="hidden" name="transfer_owner" value="{{ current_user.username }}" />
<section class="card">
<h2 class="error">{{ icon('spoiler') }} {{ gu.handle() }} is <u>unmoderated</u></h2>
<div>
<button type="submit" class="primary">Claim ownership</button>
</div>
</section>
</form>
{% endif %}
<form method="POST"> <form method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" /> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
<section class="card"> <section class="card">
@ -55,5 +71,25 @@
<button type="submit" class="primary">Save</button> <button type="submit" class="primary">Save</button>
</div> </div>
</section> </section>
<section class="card">
<h2>Management</h2>
<!-- TODO: make moderation consensual -->
{% if gu.owner == current_user or current_user.is_administrator %}
<div>
<label>
Add user as moderator:
<input type="text" name="moderator_name" placeholder="username" />
</label><br />
<label>
<input type="checkbox" name="moderator_consent" value="1" autocomplete="off" />
<u>I understand that new moderators may damage my community and the above user is trusted</u>
</label>
</div>
{% endif %}
<div>
<button type="submit" class="primary">Save</button>
</div>
</section>
</form> </form>
{% endblock %} {% endblock %}

View file

@ -38,3 +38,8 @@ def timed_cache(ttl: int, maxsize: int = 128, typed: bool = False):
def is_b32l(username: str) -> bool: def is_b32l(username: str) -> bool:
return re.fullmatch(r'[a-z2-7]+', username) return re.fullmatch(r'[a-z2-7]+', username)
def twocolon_list(s: str | None) -> list[str]:
if not s:
return []
return [x.strip() for x in s.split('::')]

View file

@ -19,12 +19,21 @@ def guild_settings(name: str):
abort(403) abort(403)
if request.method == 'POST': if request.method == 'POST':
if current_user.is_administrator and request.form.get('transfer_owner') == current_user.username:
gu.owner_id = current_user.id
db.session.add(gu)
db.session.commit()
flash(f'Claimed ownership of {gu.handle()}')
return render_template('guildsettings.html', gu=gu)
changes = False changes = False
display_name = request.form.get('display_name') display_name = request.form.get('display_name')
description = request.form.get('description') description = request.form.get('description')
exile_name = request.form.get('exile_name') exile_name = request.form.get('exile_name')
exile_reverse = 'exile_reverse' in request.form exile_reverse = 'exile_reverse' in request.form
restricted = 'restricted' in request.form restricted = 'restricted' in request.form
moderator_name = request.form.get('moderator_name')
moderator_consent = 'moderator_consent' in request.form
if description and description != gu.description: if description and description != gu.description:
changes, gu.description = True, description.strip() changes, gu.description = True, description.strip()
@ -47,11 +56,30 @@ def guild_settings(name: str):
flash(f'User \'{exile_name}\' not found, can\'t exile') flash(f'User \'{exile_name}\' not found, can\'t exile')
if restricted and restricted != gu.is_restricted: if restricted and restricted != gu.is_restricted:
changes, gu.is_restricted = True, restricted changes, gu.is_restricted = True, restricted
if moderator_consent and moderator_name:
mu = db.session.execute(select(User).where(User.username == moderator_name)).scalar()
if mu is None:
flash(f'User \'{moderator_name}\' not found')
elif mu.is_disabled:
flash('Suspended users can\'t be moderators')
elif mu.has_blocked(current_user):
flash(f'User \'{moderator_name}\' not found')
else:
mm = gu.update_member(mu)
if mm.is_moderator:
flash(f'{mu.handle()} is already a moderator')
elif mm.is_banned:
flash('Exiled users can\'t be moderators')
else:
mm.is_moderator = True
db.session.add(mm)
changes = True
if changes: if changes:
db.session.add(gu) db.session.add(gu)
db.session.commit() db.session.commit()
flash('Changes saved!') flash('Changes saved!')
return render_template('guildsettings.html', gu=gu) return render_template('guildsettings.html', gu=gu)