Compare commits
7 Commits
46704df7d9
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
40219f2b54
|
|||
|
4a45b62521
|
|||
|
fc55aaf87a
|
|||
|
db68ef2c3d
|
|||
|
a808137e5b
|
|||
|
a93a89f0df
|
|||
|
7aa3a9382e
|
73
README.md
73
README.md
@@ -1,18 +1,70 @@
|
|||||||
# Pyrom
|
# Pyrom
|
||||||
python/flask port of [porom](https://git.poto.cafe/yagich/porom)
|
pyrom is a playful home-grown forum software for the indie web borne out of frustration with social media and modern forums imitating it.
|
||||||
|
|
||||||
this is now the canonical implementation of porom. it's compatible with the database of porom.
|
the aim is not to recreate the feeling of forums from any time period. rather, it aims to serve as a lightweight alternative to other forum software packages. pyrom is lean and "fire-and-forget"; there is little necessary configuration, making it a great fit for smaller communities (though nothing prevents it from being used in larger ones.)
|
||||||
|
|
||||||
# License
|
a live example can be seen in action over at [Porom](https://forum.poto.cafe/).
|
||||||
Released under [CNPLv7+](https://thufie.lain.haus/NPL.html).
|
|
||||||
Please read the [full terms](./LICENSE.md) for proper wording.
|
## stack & structure
|
||||||
|
on the server side, pyrom is built in Python using the Flask framework. content is rendered mostly server-side with Jinja templates. the database used is SQLite.
|
||||||
|
|
||||||
|
on the client side, JS with only one library ([Bitty](https://bitty-js.com)) is used. for CSS, pyrom uses Sass.
|
||||||
|
|
||||||
|
below is an explanation of the folder structure:
|
||||||
|
|
||||||
|
- `/`
|
||||||
|
- `app/`
|
||||||
|
- `lib/` - utility libraries
|
||||||
|
- `routes/` - each `.py` file represents a "sub-app", usually the first part of the URL
|
||||||
|
- `templates/` - Jinja templates used by the routes. each subfolder corresponds to the "sub-app" that uses that template.
|
||||||
|
- `__init__.py` - creates the app
|
||||||
|
- `auth.py` - authentication helper
|
||||||
|
- `constants.py` - constant values used throughout the forum
|
||||||
|
- `db.py` - database abstraction layer and ORM library
|
||||||
|
- `migrations.py` - database migrations
|
||||||
|
- `models.py` - ORM model definitions
|
||||||
|
- `run.py` - runner script for development
|
||||||
|
- `schema.py` - database schema definition
|
||||||
|
- `config/` - configuration for the forum
|
||||||
|
- `data/`
|
||||||
|
- `_cached/` - cached versions of certain endpoints are stored here
|
||||||
|
- `db/` - the SQLite database is stored here
|
||||||
|
- `static/` - static files
|
||||||
|
- `avatars/` - user avatar uploads
|
||||||
|
- `badges/` - user badge uploads
|
||||||
|
- `css/` - CSS files generated from Sass sources
|
||||||
|
- `emoji/` - emoji images used on the forum
|
||||||
|
- `fonts/`
|
||||||
|
- `js/`
|
||||||
|
- `sass/`
|
||||||
|
- `_default.scss` - the default theme. Sass variables that other themes modify are defined here, along with the default styles. other files define the available themes.
|
||||||
|
- `build-themes.sh` - script for building Sass files into CSS
|
||||||
|
- `nginx.conf` - nginx config (production only)
|
||||||
|
- `uwsgi.ini` - uwsgi config (production only)
|
||||||
|
|
||||||
|
# license
|
||||||
|
released under [CNPLv7+](https://thufie.lain.haus/NPL.html).
|
||||||
|
please read the [full terms](./LICENSE.md) for proper wording.
|
||||||
|
|
||||||
|
# acknowledgments
|
||||||
|
|
||||||
|
pyrom uses many open-source and otherwise free-culture components. see the [THIRDPARTY](./THIRDPARTY.md) file for full credit.
|
||||||
|
|
||||||
# installing & first time setup
|
# installing & first time setup
|
||||||
## docker (production)
|
## docker (production)
|
||||||
create `config/secrets.prod.env` according to `config/secrets.prod.env.example`
|
1. clone the repo
|
||||||
|
2. create `config/secrets.prod.env` according to `config/secrets.prod.env.example`
|
||||||
|
3. create `config/pyrom_config.toml` according to `config/pyrom_config.toml.example` and modify as needed
|
||||||
|
4. make sure the `data/` folder is writable by the app:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
$ docker compose up
|
$ chmod -R 777 data/
|
||||||
|
```
|
||||||
|
|
||||||
|
5. bring up the container:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ docker compose up --build
|
||||||
```
|
```
|
||||||
|
|
||||||
- opens port 8080
|
- opens port 8080
|
||||||
@@ -20,10 +72,10 @@ $ docker compose up
|
|||||||
|
|
||||||
make sure to run it in an interactive session the first time, because it will spit out the password to the auto-created admin account.
|
make sure to run it in an interactive session the first time, because it will spit out the password to the auto-created admin account.
|
||||||
|
|
||||||
alternatively, if you already had porom running before, put the db file (`db.prod.sqlite`) in `data/db` and it will Just Work.
|
6. point your favorite proxy at `localhost:8080`
|
||||||
|
|
||||||
## manual (development)
|
## manual (development)
|
||||||
1. install python >= 3.11, sqlite3, libargon2, and imagemagick & clone repo
|
1. install python >= 3.13, sqlite3, libargon2, and imagemagick & clone repo
|
||||||
2. create a venv:
|
2. create a venv:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -59,6 +111,3 @@ $ source .venv/bin/activate
|
|||||||
$ python -m app.run
|
$ python -m app.run
|
||||||
```
|
```
|
||||||
|
|
||||||
# acknowledgments
|
|
||||||
|
|
||||||
pyrom uses many open-source and otherwise free-culture components. see the [THIRDPARTY](./THIRDPARTY.md) file for full credit.
|
|
||||||
|
|||||||
@@ -85,3 +85,18 @@ URL: https://bitty-js.com/
|
|||||||
License: CC0 1.0
|
License: CC0 1.0
|
||||||
Author: alan w smith https://www.alanwsmith.com/
|
Author: alan w smith https://www.alanwsmith.com/
|
||||||
Repo: https://github.com/alanwsmith/bitty
|
Repo: https://github.com/alanwsmith/bitty
|
||||||
|
|
||||||
|
## Flask-Caching
|
||||||
|
|
||||||
|
URL: https://flask-caching.readthedocs.io/
|
||||||
|
Copyright:
|
||||||
|
|
||||||
|
```
|
||||||
|
Copyright (c) 2010 by Thadeus Burgess.
|
||||||
|
Copyright (c) 2016 by Peter Justin.
|
||||||
|
|
||||||
|
Some rights reserved.
|
||||||
|
```
|
||||||
|
|
||||||
|
License: BSD-3-Clause ([see more](https://github.com/pallets-eco/flask-caching/blob/e59bc040cd47cd2b43e501d636d43d442c50b3ff/LICENSE))
|
||||||
|
Repo: https://github.com/pallets-eco/flask-caching
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from flask import Flask, session, request, render_template
|
from flask import Flask, session, request, render_template
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
from .models import Avatars, Users, PostHistory, Posts, MOTD, BadgeUploads
|
from .models import Avatars, Users, PostHistory, Posts, MOTD, BadgeUploads, Sessions
|
||||||
from .auth import digest
|
from .auth import digest
|
||||||
from .routes.users import is_logged_in, get_active_user, get_prefers_theme
|
from .routes.users import is_logged_in, get_active_user, get_prefers_theme
|
||||||
from .constants import (
|
from .constants import (
|
||||||
@@ -10,6 +10,7 @@ from .constants import (
|
|||||||
SIG_BANNED_TAGS, STRICT_BANNED_TAGS,
|
SIG_BANNED_TAGS, STRICT_BANNED_TAGS,
|
||||||
)
|
)
|
||||||
from .lib.babycode import babycode_to_html, babycode_to_rssxml, EMOJI, BABYCODE_VERSION
|
from .lib.babycode import babycode_to_html, babycode_to_rssxml, EMOJI, BABYCODE_VERSION
|
||||||
|
from .lib.exceptions import SiteNameMissingException
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from flask_caching import Cache
|
from flask_caching import Cache
|
||||||
import os
|
import os
|
||||||
@@ -137,6 +138,16 @@ def bind_default_badges(path):
|
|||||||
'uploaded_at': int(os.path.getmtime(real_path)),
|
'uploaded_at': int(os.path.getmtime(real_path)),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
def clear_stale_sessions():
|
||||||
|
from .db import db
|
||||||
|
with db.transaction():
|
||||||
|
now = int(time.time())
|
||||||
|
stale_sessions = Sessions.findall([
|
||||||
|
('expires_at', '<', now)
|
||||||
|
])
|
||||||
|
for sess in stale_sessions:
|
||||||
|
sess.delete()
|
||||||
|
|
||||||
|
|
||||||
cache = Cache()
|
cache = Cache()
|
||||||
|
|
||||||
@@ -165,6 +176,8 @@ def create_app():
|
|||||||
load_dotenv()
|
load_dotenv()
|
||||||
else:
|
else:
|
||||||
app.config["DB_PATH"] = "data/db/db.prod.sqlite"
|
app.config["DB_PATH"] = "data/db/db.prod.sqlite"
|
||||||
|
if not app.config["SERVER_NAME"]:
|
||||||
|
raise SiteNameMissingException()
|
||||||
|
|
||||||
app.config["SECRET_KEY"] = os.getenv("FLASK_SECRET_KEY")
|
app.config["SECRET_KEY"] = os.getenv("FLASK_SECRET_KEY")
|
||||||
|
|
||||||
@@ -223,6 +236,8 @@ def create_app():
|
|||||||
create_admin()
|
create_admin()
|
||||||
create_deleted_user()
|
create_deleted_user()
|
||||||
|
|
||||||
|
clear_stale_sessions()
|
||||||
|
|
||||||
reparse_babycode()
|
reparse_babycode()
|
||||||
|
|
||||||
bind_default_badges(app.config['BADGES_PATH'])
|
bind_default_badges(app.config['BADGES_PATH'])
|
||||||
|
|||||||
@@ -196,13 +196,12 @@ class RSSXMLRenderer(BabycodeRenderer):
|
|||||||
|
|
||||||
def make_mention(self, e):
|
def make_mention(self, e):
|
||||||
from ..models import Users
|
from ..models import Users
|
||||||
from flask import url_for, current_app
|
from flask import url_for
|
||||||
with current_app.test_request_context('/'):
|
target_user = Users.find({'username': e['name'].lower()})
|
||||||
target_user = Users.find({'username': e['name'].lower()})
|
if not target_user:
|
||||||
if not target_user:
|
return f"@{e['name']}"
|
||||||
return f"@{e['name']}"
|
|
||||||
|
|
||||||
return f'<a href="{url_for('users.page', username=target_user.username, _external=True)}" title="@{target_user.username}">{target_user.get_readable_name()}</a>'
|
return f'<a href="{url_for('users.page', username=target_user.username, _external=True)}" title="@{target_user.username}">{'@' if not target_user.has_display_name() else ''}{target_user.get_readable_name()}</a>'
|
||||||
|
|
||||||
|
|
||||||
NAMED_COLORS = [
|
NAMED_COLORS = [
|
||||||
|
|||||||
9
app/lib/exceptions.py
Normal file
9
app/lib/exceptions.py
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
class MissingConfigurationException(Exception):
|
||||||
|
def __init__(self, configuration_field: str):
|
||||||
|
message = f"Missing configuration field '{configuration_field}'"
|
||||||
|
super().__init__(message)
|
||||||
|
|
||||||
|
|
||||||
|
class SiteNameMissingException(MissingConfigurationException):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__('SITE_NAME')
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
from flask import Blueprint, redirect, url_for, render_template
|
from flask import Blueprint, redirect, url_for, render_template
|
||||||
from app import cache
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
bp = Blueprint("app", __name__, url_prefix = "/")
|
bp = Blueprint("app", __name__, url_prefix = "/")
|
||||||
@@ -7,12 +6,3 @@ bp = Blueprint("app", __name__, url_prefix = "/")
|
|||||||
@bp.route("/")
|
@bp.route("/")
|
||||||
def index():
|
def index():
|
||||||
return redirect(url_for("topics.all_topics"))
|
return redirect(url_for("topics.all_topics"))
|
||||||
|
|
||||||
@bp.route("/cache-test")
|
|
||||||
def cache_test():
|
|
||||||
test_value = cache.get('test')
|
|
||||||
if test_value is None:
|
|
||||||
test_value = 'cached_value_' + str(datetime.now())
|
|
||||||
cache.set('test', test_value, timeout=10)
|
|
||||||
return f"set cache: {test_value}"
|
|
||||||
return f"cached: {test_value}"
|
|
||||||
|
|||||||
@@ -74,7 +74,17 @@ def validate_and_create_badge(input_image, filename):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
def is_logged_in():
|
def is_logged_in():
|
||||||
return "pyrom_session_key" in session
|
if "pyrom_session_key" not in session:
|
||||||
|
return False
|
||||||
|
sess = Sessions.find({"key": session["pyrom_session_key"]})
|
||||||
|
if not sess:
|
||||||
|
return False
|
||||||
|
if sess.expires_at < int(time.time()):
|
||||||
|
session.clear()
|
||||||
|
sess.delete()
|
||||||
|
flash('Your session expired.;Please log in again.', InfoboxKind.INFO)
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
def get_active_user():
|
def get_active_user():
|
||||||
@@ -83,6 +93,8 @@ def get_active_user():
|
|||||||
sess = Sessions.find({"key": session["pyrom_session_key"]})
|
sess = Sessions.find({"key": session["pyrom_session_key"]})
|
||||||
if not sess:
|
if not sess:
|
||||||
return None
|
return None
|
||||||
|
if sess.expires_at < int(time.time()):
|
||||||
|
return None
|
||||||
return Users.find({"id": sess.user_id})
|
return Users.find({"id": sess.user_id})
|
||||||
|
|
||||||
|
|
||||||
@@ -884,6 +896,10 @@ def delete_page_confirm(username):
|
|||||||
flash('Incorrect password.', InfoboxKind.ERROR)
|
flash('Incorrect password.', InfoboxKind.ERROR)
|
||||||
return redirect(url_for('.delete_page', username=username))
|
return redirect(url_for('.delete_page', username=username))
|
||||||
|
|
||||||
|
if target_user.is_admin():
|
||||||
|
flash('You cannot delete the admin account.', InfoboxKind.ERROR)
|
||||||
|
return redirect(url_for('.delete_page', username=username))
|
||||||
|
|
||||||
anonymize_user(target_user.id)
|
anonymize_user(target_user.id)
|
||||||
sessions = Sessions.findall({'user_id': int(target_user.id)})
|
sessions = Sessions.findall({'user_id': int(target_user.id)})
|
||||||
for session_obj in sessions:
|
for session_obj in sessions:
|
||||||
|
|||||||
@@ -1,11 +1,31 @@
|
|||||||
|
### REQUIRED CONFIGURATION
|
||||||
|
## the following settings are required.
|
||||||
|
## the app will not work if they are missing.
|
||||||
|
|
||||||
|
# the domain name you will be serving Pyrom from, without the scheme, including the subdomain(s).
|
||||||
|
# this is overridden by the app in development.
|
||||||
|
# used for generating URLs.
|
||||||
|
# the app will not start if this field is missing.
|
||||||
|
SERVER_NAME = "forum.your.domain"
|
||||||
|
|
||||||
|
### OPTIONAL CONFIGURATION
|
||||||
|
## the following settings are set to their default values.
|
||||||
|
## you can override any of them.
|
||||||
|
|
||||||
|
# your forum's name, shown on the header.
|
||||||
SITE_NAME = "Pyrom"
|
SITE_NAME = "Pyrom"
|
||||||
DISABLE_SIGNUP = false # if true, no one can sign up.
|
|
||||||
|
# if true, users can not sign up manually. see the following two settings.
|
||||||
|
DISABLE_SIGNUP = false
|
||||||
|
|
||||||
# if neither of the following two options is true,
|
# if neither of the following two options is true,
|
||||||
# no one can sign up. this may be useful later when/if LDAP is implemented.
|
# no one can sign up. this may be useful later when/if LDAP is implemented.
|
||||||
|
|
||||||
MODS_CAN_INVITE = true # if true, allows moderators to create invite links. useless unless DISABLE_SIGNUP to be true.
|
# if true, allows moderators to create invite links. useless unless DISABLE_SIGNUP is true.
|
||||||
USERS_CAN_INVITE = false # if true, allows users to create invite links. useless unless DISABLE_SIGNUP to be true.
|
MODS_CAN_INVITE = true
|
||||||
|
|
||||||
|
# if true, allows users to create invite links. useless unless DISABLE_SIGNUP is true.
|
||||||
|
USERS_CAN_INVITE = false
|
||||||
|
|
||||||
# contact information, will be shown in /guides/contact
|
# contact information, will be shown in /guides/contact
|
||||||
# some babycodes allowed
|
# some babycodes allowed
|
||||||
|
|||||||
Reference in New Issue
Block a user