Merge pull request 'main' (#3) from rimu/pyfedi:main into main

Reviewed-on: https://codeberg.org/xmatt/pyfedi/pulls/3
This commit is contained in:
xmatt 2024-12-21 12:11:44 +00:00
commit a05a14d68b
25 changed files with 614 additions and 443 deletions

View file

@ -4,7 +4,7 @@ FROM --platform=$BUILDPLATFORM python:3-alpine AS builder
RUN apk update RUN apk update
RUN apk add pkgconfig RUN apk add pkgconfig
RUN apk add --virtual build-deps gcc python3-dev musl-dev tesseract-ocr tesseract-ocr-data-eng ffmpeg RUN apk add --virtual build-deps gcc python3-dev musl-dev tesseract-ocr tesseract-ocr-data-eng
WORKDIR /app WORKDIR /app
COPY . /app COPY . /app

View file

@ -1,5 +1,6 @@
# Contents # Contents
* [Choose your path - easy way or hard way](#choose-path)
* [Setup Database](#setup-database) * [Setup Database](#setup-database)
* [Install Python Libraries](#install-python-libraries) * [Install Python Libraries](#install-python-libraries)
* [Install additional requirements](#install-additional-requirements) * [Install additional requirements](#install-additional-requirements)
@ -14,6 +15,110 @@
* [Notes for Windows (WSL2)](#notes-for-windows-wsl2) * [Notes for Windows (WSL2)](#notes-for-windows-wsl2)
* [Notes for Pip Package Management](#notes-for-pip-package-management) * [Notes for Pip Package Management](#notes-for-pip-package-management)
<div id="choose-path"></div>
## Do you want this the easy way or the hard way?
### Easy way: docker
Docker can be used to create an isolated environment that is separate from the host server and starts from a consistent
configuration. While it is quicker and easier, it's not to everyone's taste.
* Clone PieFed into a new directory
```bash
git clone https://codeberg.org/rimu/pyfedi.git
```
* Copy suggested docker config
```bash
cd pyfedi
cp env.docker.sample .env.docker
```
* Edit docker environment file
Open .env.docker in your text editor, set SECRET_KEY to something random and set SERVER_NAME to your domain name,
WITHOUT the https:// at the front. The database login details doesn't really need to be changed because postgres will be
locked away inside it's own docker network that only PieFed can access but if you want to change POSTGRES_PASSWORD go ahead
just be sure to update DATABASE_URL accordingly.
Check out compose.yaml and see if it is to your liking. Note the port (8030) and volume definitions - they might need to be
tweaked.
* First startup
This will take a few minutes.
```bash
export DOCKER_BUILDKIT=1
docker-compose up --build
```
After a while the gibberish will stop scrolling past. If you see errors let us know at [https://piefed.social/c/piefed_help](https://piefed.social/c/piefed_help).
* Networking
You need to somehow to allow client connections from outside to access port 8030 on your server. The details of this is outside the scope
of this article. You could use a nginx reverse proxy, a cloudflare zero trust tunnel, tailscale, whatever. Just make sure it has SSL on
it as PieFed assumes you're making requests that start with https://your-domain.
Once you have the networking set up, go to https://your-domain in your browser and see if the docker output in your terminal
shows signs of reacting to the request. There will be an error showing up in the console because we haven't done the next step yet.
* Database initialization
This must be done once and once only. Doing this will wipe all existing data in your instance so do not do it unless you have a
brand new instance.
Open a shell inside the PieFed docker container:
`docker exec -it piefed_app1 sh`
Inside the container, run the initialization command:
```
export FLASK_APP=pyfedi.py
flask init-db
```
Among other things this process will get you set up with a username and password. Don't use 'admin' as the user name, script kiddies love that one.
* The moment of truth
Go to https://your-domain in your web browser and PieFed should appear. Log in with the username and password from the previous step.
At this point docker is pretty much Ok so you don't need to see the terminal output as readily. Hit Ctrl + C to close down docker and then run
```bash
docker-compose up -d
```
to have PieFed run in the background.
* But wait there's more
Until you set the right environment variables, PieFed won't be able to send email. Check out env.sample for some hints.
When you have a new value to set, add it to .env.docker and then restart docker with:
```
docker-compose down && docker-compose up -d
```
There are also regular cron jobs that need to be run. Set up cron on the host to run those scripts inside the container - see the Cron
section of this document for details.
You probably want a Captcha on the registration form - more environment variables.
CDN, CloudFlare. More environment variables.
All this is explained in the bare metal guide, below.
### Hard way: bare metal
Read on
<div id="setup-database"></div> <div id="setup-database"></div>
## Setup Database ## Setup Database
@ -77,7 +182,7 @@ sudo apt install tesseract-ocr
## Setup PyFedi ## Setup PyFedi
* Clone PyFedi * Clone PieFed
```bash ```bash
git clone https://codeberg.org/rimu/pyfedi.git git clone https://codeberg.org/rimu/pyfedi.git

View file

@ -10,7 +10,6 @@ import httpx
import redis import redis
from flask import current_app, request, g, url_for, json from flask import current_app, request, g, url_for, json
from flask_babel import _ from flask_babel import _
from requests import JSONDecodeError
from sqlalchemy import text, func, desc from sqlalchemy import text, func, desc
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
@ -29,7 +28,7 @@ import pytesseract
from app.utils import get_request, allowlist_html, get_setting, ap_datetime, markdown_to_html, \ from app.utils import get_request, allowlist_html, get_setting, ap_datetime, markdown_to_html, \
is_image_url, domain_from_url, gibberish, ensure_directory_exists, head_request, \ is_image_url, domain_from_url, gibberish, ensure_directory_exists, head_request, \
shorten_string, remove_tracking_from_link, \ shorten_string, remove_tracking_from_link, \
microblog_content_to_title, generate_image_from_video_url, is_video_url, \ microblog_content_to_title, is_video_url, \
notification_subscribers, communities_banned_from, actor_contains_blocked_words, \ notification_subscribers, communities_banned_from, actor_contains_blocked_words, \
html_to_text, add_to_modlog_activitypub, joined_communities, \ html_to_text, add_to_modlog_activitypub, joined_communities, \
moderating_communities, get_task_session, is_video_hosting_site, opengraph_parse moderating_communities, get_task_session, is_video_hosting_site, opengraph_parse
@ -1009,148 +1008,106 @@ def make_image_sizes_async(file_id, thumbnail_width, medium_width, directory, to
session = get_task_session() session = get_task_session()
file: File = session.query(File).get(file_id) file: File = session.query(File).get(file_id)
if file and file.source_url: if file and file.source_url:
# Videos (old code. not invoked because file.source_url won't end .mp4 or .webm) try:
if file.source_url.endswith('.mp4') or file.source_url.endswith('.webm'): source_image_response = get_request(file.source_url)
new_filename = gibberish(15) except:
pass
# set up the storage directory
directory = f'app/static/media/{directory}/' + new_filename[0:2] + '/' + new_filename[2:4]
ensure_directory_exists(directory)
# file path and names to store the resized images on disk
final_place = os.path.join(directory, new_filename + '.jpg')
final_place_thumbnail = os.path.join(directory, new_filename + '_thumbnail.webp')
try:
generate_image_from_video_url(file.source_url, final_place)
except Exception as e:
return
if final_place:
image = Image.open(final_place)
img_width = image.width
# Resize the image to medium
if medium_width:
if img_width > medium_width:
image.thumbnail((medium_width, medium_width))
image.save(final_place)
file.file_path = final_place
file.width = image.width
file.height = image.height
# Resize the image to a thumbnail (webp)
if thumbnail_width:
if img_width > thumbnail_width:
image.thumbnail((thumbnail_width, thumbnail_width))
image.save(final_place_thumbnail, format="WebP", quality=93)
file.thumbnail_path = final_place_thumbnail
file.thumbnail_width = image.width
file.thumbnail_height = image.height
session.commit()
# Images
else: else:
try: if source_image_response.status_code == 404 and '/api/v3/image_proxy' in file.source_url:
source_image_response = get_request(file.source_url) source_image_response.close()
except: # Lemmy failed to retrieve the image but we might have better luck. Example source_url: https://slrpnk.net/api/v3/image_proxy?url=https%3A%2F%2Fi.guim.co.uk%2Fimg%2Fmedia%2F24e87cb4d730141848c339b3b862691ca536fb26%2F0_164_3385_2031%2Fmaster%2F3385.jpg%3Fwidth%3D1200%26height%3D630%26quality%3D85%26auto%3Dformat%26fit%3Dcrop%26overlay-align%3Dbottom%252Cleft%26overlay-width%3D100p%26overlay-base64%3DL2ltZy9zdGF0aWMvb3ZlcmxheXMvdGctZGVmYXVsdC5wbmc%26enable%3Dupscale%26s%3D0ec9d25a8cb5db9420471054e26cfa63
pass # The un-proxied image url is the query parameter called 'url'
else: parsed_url = urlparse(file.source_url)
if source_image_response.status_code == 404 and '/api/v3/image_proxy' in file.source_url: query_params = parse_qs(parsed_url.query)
source_image_response.close() if 'url' in query_params:
# Lemmy failed to retrieve the image but we might have better luck. Example source_url: https://slrpnk.net/api/v3/image_proxy?url=https%3A%2F%2Fi.guim.co.uk%2Fimg%2Fmedia%2F24e87cb4d730141848c339b3b862691ca536fb26%2F0_164_3385_2031%2Fmaster%2F3385.jpg%3Fwidth%3D1200%26height%3D630%26quality%3D85%26auto%3Dformat%26fit%3Dcrop%26overlay-align%3Dbottom%252Cleft%26overlay-width%3D100p%26overlay-base64%3DL2ltZy9zdGF0aWMvb3ZlcmxheXMvdGctZGVmYXVsdC5wbmc%26enable%3Dupscale%26s%3D0ec9d25a8cb5db9420471054e26cfa63 url_value = query_params['url'][0]
# The un-proxied image url is the query parameter called 'url' source_image_response = get_request(url_value)
parsed_url = urlparse(file.source_url) else:
query_params = parse_qs(parsed_url.query) source_image_response = None
if 'url' in query_params: if source_image_response and source_image_response.status_code == 200:
url_value = query_params['url'][0] content_type = source_image_response.headers.get('content-type')
source_image_response = get_request(url_value) if content_type:
else: if content_type.startswith('image') or (content_type == 'application/octet-stream' and file.source_url.endswith('.avif')):
source_image_response = None source_image = source_image_response.content
if source_image_response and source_image_response.status_code == 200: source_image_response.close()
content_type = source_image_response.headers.get('content-type')
if content_type:
if content_type.startswith('image') or (content_type == 'application/octet-stream' and file.source_url.endswith('.avif')):
source_image = source_image_response.content
source_image_response.close()
content_type_parts = content_type.split('/') content_type_parts = content_type.split('/')
if content_type_parts: if content_type_parts:
# content type headers often are just 'image/jpeg' but sometimes 'image/jpeg;charset=utf8' # content type headers often are just 'image/jpeg' but sometimes 'image/jpeg;charset=utf8'
# Remove ;charset=whatever # Remove ;charset=whatever
main_part = content_type.split(';')[0] main_part = content_type.split(';')[0]
# Split the main part on the '/' character and take the second part # Split the main part on the '/' character and take the second part
file_ext = '.' + main_part.split('/')[1] file_ext = '.' + main_part.split('/')[1]
file_ext = file_ext.strip() # just to be sure file_ext = file_ext.strip() # just to be sure
if file_ext == '.jpeg': if file_ext == '.jpeg':
file_ext = '.jpg' file_ext = '.jpg'
elif file_ext == '.svg+xml': elif file_ext == '.svg+xml':
return # no need to resize SVG images return # no need to resize SVG images
elif file_ext == '.octet-stream': elif file_ext == '.octet-stream':
file_ext = '.avif' file_ext = '.avif'
else: else:
file_ext = os.path.splitext(file.source_url)[1] file_ext = os.path.splitext(file.source_url)[1]
file_ext = file_ext.replace('%3f', '?') # sometimes urls are not decoded properly file_ext = file_ext.replace('%3f', '?') # sometimes urls are not decoded properly
if '?' in file_ext: if '?' in file_ext:
file_ext = file_ext.split('?')[0] file_ext = file_ext.split('?')[0]
new_filename = gibberish(15) new_filename = gibberish(15)
# set up the storage directory # set up the storage directory
directory = f'app/static/media/{directory}/' + new_filename[0:2] + '/' + new_filename[2:4] directory = f'app/static/media/{directory}/' + new_filename[0:2] + '/' + new_filename[2:4]
ensure_directory_exists(directory) ensure_directory_exists(directory)
# file path and names to store the resized images on disk # file path and names to store the resized images on disk
final_place = os.path.join(directory, new_filename + file_ext) final_place = os.path.join(directory, new_filename + file_ext)
final_place_thumbnail = os.path.join(directory, new_filename + '_thumbnail.webp') final_place_thumbnail = os.path.join(directory, new_filename + '_thumbnail.webp')
if file_ext == '.avif': # this is quite a big plugin so we'll only load it if necessary if file_ext == '.avif': # this is quite a big plugin so we'll only load it if necessary
import pillow_avif import pillow_avif
# Load image data into Pillow # Load image data into Pillow
Image.MAX_IMAGE_PIXELS = 89478485 Image.MAX_IMAGE_PIXELS = 89478485
image = Image.open(BytesIO(source_image)) image = Image.open(BytesIO(source_image))
image = ImageOps.exif_transpose(image) image = ImageOps.exif_transpose(image)
img_width = image.width img_width = image.width
img_height = image.height img_height = image.height
# Resize the image to medium # Resize the image to medium
if medium_width: if medium_width:
if img_width > medium_width: if img_width > medium_width:
image.thumbnail((medium_width, medium_width)) image.thumbnail((medium_width, medium_width))
image.save(final_place) image.save(final_place)
file.file_path = final_place file.file_path = final_place
file.width = image.width file.width = image.width
file.height = image.height file.height = image.height
# Resize the image to a thumbnail (webp) # Resize the image to a thumbnail (webp)
if thumbnail_width: if thumbnail_width:
if img_width > thumbnail_width: if img_width > thumbnail_width:
image.thumbnail((thumbnail_width, thumbnail_width)) image.thumbnail((thumbnail_width, thumbnail_width))
image.save(final_place_thumbnail, format="WebP", quality=93) image.save(final_place_thumbnail, format="WebP", quality=93)
file.thumbnail_path = final_place_thumbnail file.thumbnail_path = final_place_thumbnail
file.thumbnail_width = image.width file.thumbnail_width = image.width
file.thumbnail_height = image.height file.thumbnail_height = image.height
session.commit() session.commit()
# Alert regarding fascist meme content # Alert regarding fascist meme content
if toxic_community and img_width < 2000: # images > 2000px tend to be real photos instead of 4chan screenshots. if toxic_community and img_width < 2000: # images > 2000px tend to be real photos instead of 4chan screenshots.
try: try:
image_text = pytesseract.image_to_string(Image.open(BytesIO(source_image)).convert('L'), timeout=30) image_text = pytesseract.image_to_string(Image.open(BytesIO(source_image)).convert('L'), timeout=30)
except Exception as e: except Exception as e:
image_text = '' image_text = ''
if 'Anonymous' in image_text and ('No.' in image_text or ' N0' in image_text): # chan posts usually contain the text 'Anonymous' and ' No.12345' if 'Anonymous' in image_text and ('No.' in image_text or ' N0' in image_text): # chan posts usually contain the text 'Anonymous' and ' No.12345'
post = Post.query.filter_by(image_id=file.id).first() post = Post.query.filter_by(image_id=file.id).first()
notification = Notification(title='Review this', notification = Notification(title='Review this',
user_id=1, user_id=1,
author_id=post.user_id, author_id=post.user_id,
url=url_for('activitypub.post_ap', post_id=post.id)) url=url_for('activitypub.post_ap', post_id=post.id))
session.add(notification) session.add(notification)
session.commit() session.commit()
def find_reply_parent(in_reply_to: str) -> Tuple[int, int, int]: def find_reply_parent(in_reply_to: str) -> Tuple[int, int, int]:

View file

@ -1147,60 +1147,30 @@ def admin_users():
page = request.args.get('page', 1, type=int) page = request.args.get('page', 1, type=int)
search = request.args.get('search', '') search = request.args.get('search', '')
local_remote = request.args.get('local_remote', '') local_remote = request.args.get('local_remote', '')
sort_by = request.args.get('sort_by', 'last_seen DESC')
last_seen = request.args.get('last_seen', 0, type=int)
sort_by_btn = request.args.get('sort_by_btn', '')
if sort_by_btn:
return redirect(url_for('admin.admin_users', page=page, search=search, local_remote=local_remote, sort_by=sort_by_btn, last_seen=last_seen))
users = User.query.filter_by(deleted=False) users = User.query.filter_by(deleted=False)
if local_remote == 'local': if local_remote == 'local':
users = users.filter_by(ap_id=None) users = users.filter_by(ap_id=None)
if local_remote == 'remote': elif local_remote == 'remote':
users = users.filter(User.ap_id != None) users = users.filter(User.ap_id != None)
if search: if search:
users = users.filter(User.email.ilike(f"%{search}%")) users = users.filter(User.email.ilike(f"%{search}%"))
users = users.order_by(User.user_name).paginate(page=page, per_page=1000, error_out=False) if last_seen > 0:
users = users.filter(User.last_seen > utcnow() - timedelta(days=last_seen))
users = users.order_by(text('"user".' + sort_by))
users = users.paginate(page=page, per_page=1000, error_out=False)
next_url = url_for('admin.admin_users', page=users.next_num) if users.has_next else None next_url = url_for('admin.admin_users', page=users.next_num, search=search, local_remote=local_remote, sort_by=sort_by, last_seen=last_seen) if users.has_next else None
prev_url = url_for('admin.admin_users', page=users.prev_num) if users.has_prev and page != 1 else None prev_url = url_for('admin.admin_users', page=users.prev_num, search=search, local_remote=local_remote, sort_by=sort_by, last_seen=last_seen) if users.has_prev and page != 1 else None
return render_template('admin/users.html', title=_('Users'), next_url=next_url, prev_url=prev_url, users=users, return render_template('admin/users.html', title=_('Users'), next_url=next_url, prev_url=prev_url, users=users,
local_remote=local_remote, search=search, local_remote=local_remote, search=search, sort_by=sort_by, last_seen=last_seen,
moderating_communities=moderating_communities(current_user.get_id()),
joined_communities=joined_communities(current_user.get_id()),
menu_topics=menu_topics(),
site=g.site
)
@bp.route('/users/trash', methods=['GET'])
@login_required
@permission_required('administer all users')
def admin_users_trash():
page = request.args.get('page', 1, type=int)
search = request.args.get('search', '')
local_remote = request.args.get('local_remote', '')
type = request.args.get('type', 'bad_rep')
users = User.query.filter_by(deleted=False)
if local_remote == 'local':
users = users.filter_by(ap_id=None)
if local_remote == 'remote':
users = users.filter(User.ap_id != None)
if search:
users = users.filter(User.email.ilike(f"%{search}%"))
if type == '' or type == 'bad_rep':
users = users.filter(User.last_seen > utcnow() - timedelta(days=7))
users = users.filter(User.reputation < -10)
users = users.order_by(User.reputation).paginate(page=page, per_page=1000, error_out=False)
elif type == 'bad_attitude':
users = users.filter(User.last_seen > utcnow() - timedelta(days=7))
users = users.filter(User.attitude < 0.0).filter(User.reputation < -10)
users = users.order_by(User.attitude).paginate(page=page, per_page=1000, error_out=False)
next_url = url_for('admin.admin_users_trash', page=users.next_num, search=search, local_remote=local_remote, type=type) if users.has_next else None
prev_url = url_for('admin.admin_users_trash', page=users.prev_num, search=search, local_remote=local_remote, type=type) if users.has_prev and page != 1 else None
return render_template('admin/users_trash.html', title=_('Problematic users'), next_url=next_url, prev_url=prev_url, users=users,
local_remote=local_remote, search=search, type=type,
moderating_communities=moderating_communities(current_user.get_id()), moderating_communities=moderating_communities(current_user.get_id()),
joined_communities=joined_communities(current_user.get_id()), joined_communities=joined_communities(current_user.get_id()),
menu_topics=menu_topics(), menu_topics=menu_topics(),

View file

@ -220,7 +220,6 @@ def list_communities():
next_url=next_url, prev_url=prev_url, current_user=current_user, next_url=next_url, prev_url=prev_url, current_user=current_user,
topics=topics, languages=languages, topic_id=topic_id, language_id=language_id, sort_by=sort_by, topics=topics, languages=languages, topic_id=topic_id, language_id=language_id, sort_by=sort_by,
low_bandwidth=low_bandwidth, moderating_communities=moderating_communities(current_user.get_id()), low_bandwidth=low_bandwidth, moderating_communities=moderating_communities(current_user.get_id()),
joined_communities=joined_communities(current_user.get_id()),
menu_topics=menu_topics(), site=g.site) menu_topics=menu_topics(), site=g.site)
@ -272,11 +271,11 @@ def list_local_communities():
next_url=next_url, prev_url=prev_url, current_user=current_user, next_url=next_url, prev_url=prev_url, current_user=current_user,
topics=topics, languages=languages, topic_id=topic_id, language_id=language_id, sort_by=sort_by, topics=topics, languages=languages, topic_id=topic_id, language_id=language_id, sort_by=sort_by,
low_bandwidth=low_bandwidth, moderating_communities=moderating_communities(current_user.get_id()), low_bandwidth=low_bandwidth, moderating_communities=moderating_communities(current_user.get_id()),
joined_communities=joined_communities(current_user.get_id()),
menu_topics=menu_topics(), site=g.site) menu_topics=menu_topics(), site=g.site)
@bp.route('/communities/subscribed', methods=['GET']) @bp.route('/communities/subscribed', methods=['GET'])
@login_required
def list_subscribed_communities(): def list_subscribed_communities():
verification_warning() verification_warning()
search_param = request.args.get('search', '') search_param = request.args.get('search', '')
@ -287,36 +286,39 @@ def list_subscribed_communities():
sort_by = request.args.get('sort_by', 'post_reply_count desc') sort_by = request.args.get('sort_by', 'post_reply_count desc')
topics = Topic.query.order_by(Topic.name).all() topics = Topic.query.order_by(Topic.name).all()
languages = Language.query.order_by(Language.name).all() languages = Language.query.order_by(Language.name).all()
if current_user.is_authenticated: # get all the communities
communities = Community.query.filter_by(banned=False).join(CommunityMember).filter(CommunityMember.user_id == current_user.id) all_communities = Community.query.filter_by(banned=False)
# get the user's joined communities
user_joined_communities = joined_communities(current_user.id)
# get the joined community ids list
joined_ids = []
for jc in user_joined_communities:
joined_ids.append(jc.id)
# filter down to just the joined communities
communities = all_communities.filter(Community.id.in_(joined_ids))
if search_param == '': if search_param == '':
pass pass
else:
communities = communities.filter(or_(Community.title.ilike(f"%{search_param}%"), Community.ap_id.ilike(f"%{search_param}%")))
if topic_id != 0:
communities = communities.filter_by(topic_id=topic_id)
if language_id != 0:
communities = communities.join(community_language).filter(community_language.c.language_id == language_id)
banned_from = communities_banned_from(current_user.id)
if banned_from:
communities = communities.filter(Community.id.not_in(banned_from))
communities = communities.order_by(text('community.' + sort_by))
# Pagination
communities = communities.paginate(page=page, per_page=250 if current_user.is_authenticated and not low_bandwidth else 50,
error_out=False)
next_url = url_for('main.list_subscribed_communities', page=communities.next_num, sort_by=sort_by, language_id=language_id) if communities.has_next else None
prev_url = url_for('main.list_subscribed_communities', page=communities.prev_num, sort_by=sort_by, language_id=language_id) if communities.has_prev and page != 1 else None
else: else:
communities = [] communities = communities.filter(or_(Community.title.ilike(f"%{search_param}%"), Community.ap_id.ilike(f"%{search_param}%")))
next_url = None
prev_url = None if topic_id != 0:
communities = communities.filter_by(topic_id=topic_id)
if language_id != 0:
communities = communities.join(community_language).filter(community_language.c.language_id == language_id)
banned_from = communities_banned_from(current_user.id)
if banned_from:
communities = communities.filter(Community.id.not_in(banned_from))
communities = communities.order_by(text('community.' + sort_by))
# Pagination
communities = communities.paginate(page=page, per_page=250 if current_user.is_authenticated and not low_bandwidth else 50,
error_out=False)
next_url = url_for('main.list_subscribed_communities', page=communities.next_num, sort_by=sort_by, language_id=language_id) if communities.has_next else None
prev_url = url_for('main.list_subscribed_communities', page=communities.prev_num, sort_by=sort_by, language_id=language_id) if communities.has_prev and page != 1 else None
return render_template('list_communities.html', communities=communities, search=search_param, title=_('Joined Communities'), return render_template('list_communities.html', communities=communities, search=search_param, title=_('Joined Communities'),
SUBSCRIPTION_PENDING=SUBSCRIPTION_PENDING, SUBSCRIPTION_MEMBER=SUBSCRIPTION_MEMBER, SUBSCRIPTION_PENDING=SUBSCRIPTION_PENDING, SUBSCRIPTION_MEMBER=SUBSCRIPTION_MEMBER,
@ -324,7 +326,6 @@ def list_subscribed_communities():
next_url=next_url, prev_url=prev_url, current_user=current_user, next_url=next_url, prev_url=prev_url, current_user=current_user,
topics=topics, languages=languages, topic_id=topic_id, language_id=language_id, sort_by=sort_by, topics=topics, languages=languages, topic_id=topic_id, language_id=language_id, sort_by=sort_by,
low_bandwidth=low_bandwidth, moderating_communities=moderating_communities(current_user.get_id()), low_bandwidth=low_bandwidth, moderating_communities=moderating_communities(current_user.get_id()),
joined_communities=joined_communities(current_user.get_id()),
menu_topics=menu_topics(), site=g.site) menu_topics=menu_topics(), site=g.site)
@ -339,49 +340,43 @@ def list_not_subscribed_communities():
sort_by = request.args.get('sort_by', 'post_reply_count desc') sort_by = request.args.get('sort_by', 'post_reply_count desc')
topics = Topic.query.order_by(Topic.name).all() topics = Topic.query.order_by(Topic.name).all()
languages = Language.query.order_by(Language.name).all() languages = Language.query.order_by(Language.name).all()
if current_user.is_authenticated: # get all communities
# get all communities all_communities = Community.query.filter_by(banned=False)
all_communities = Community.query.filter_by(banned=False) # get the user's joined communities
# get the user's joined communities joined_communities = Community.query.filter_by(banned=False).join(CommunityMember).filter(CommunityMember.user_id == current_user.id)
joined_communities = Community.query.filter_by(banned=False).join(CommunityMember).filter(CommunityMember.user_id == current_user.id) # get the joined community ids list
# get the joined community ids list joined_ids = []
joined_ids = [] for jc in joined_communities:
for jc in joined_communities: joined_ids.append(jc.id)
joined_ids.append(jc.id) # filter out the joined communities from all communities
# filter out the joined communities from all communities communities = all_communities.filter(Community.id.not_in(joined_ids))
communities = all_communities.filter(Community.id.not_in(joined_ids))
if search_param == '':
pass
else:
communities = communities.filter(or_(Community.title.ilike(f"%{search_param}%"), Community.ap_id.ilike(f"%{search_param}%")))
if topic_id != 0:
communities = communities.filter_by(topic_id=topic_id)
if language_id != 0:
communities = communities.join(community_language).filter(community_language.c.language_id == language_id)
banned_from = communities_banned_from(current_user.id)
if banned_from:
communities = communities.filter(Community.id.not_in(banned_from))
if current_user.hide_nsfw == 1:
communities = communities.filter(Community.nsfw == False)
if current_user.hide_nsfl == 1:
communities = communities.filter(Community.nsfl == False)
communities = communities.order_by(text('community.' + sort_by))
# Pagination
communities = communities.paginate(page=page, per_page=250 if current_user.is_authenticated and not low_bandwidth else 50,
error_out=False)
next_url = url_for('main.list_not_subscribed_communities', page=communities.next_num, sort_by=sort_by, language_id=language_id) if communities.has_next else None
prev_url = url_for('main.list_not_subscribed_communities', page=communities.prev_num, sort_by=sort_by, language_id=language_id) if communities.has_prev and page != 1 else None
if search_param == '':
pass
else: else:
communities = [] communities = communities.filter(or_(Community.title.ilike(f"%{search_param}%"), Community.ap_id.ilike(f"%{search_param}%")))
next_url = None
prev_url = None if topic_id != 0:
communities = communities.filter_by(topic_id=topic_id)
if language_id != 0:
communities = communities.join(community_language).filter(community_language.c.language_id == language_id)
banned_from = communities_banned_from(current_user.id)
if banned_from:
communities = communities.filter(Community.id.not_in(banned_from))
if current_user.hide_nsfw == 1:
communities = communities.filter(Community.nsfw == False)
if current_user.hide_nsfl == 1:
communities = communities.filter(Community.nsfl == False)
communities = communities.order_by(text('community.' + sort_by))
# Pagination
communities = communities.paginate(page=page, per_page=250 if current_user.is_authenticated and not low_bandwidth else 50,
error_out=False)
next_url = url_for('main.list_not_subscribed_communities', page=communities.next_num, sort_by=sort_by, language_id=language_id) if communities.has_next else None
prev_url = url_for('main.list_not_subscribed_communities', page=communities.prev_num, sort_by=sort_by, language_id=language_id) if communities.has_prev and page != 1 else None
return render_template('list_communities.html', communities=communities, search=search_param, title=_('Not Joined Communities'), return render_template('list_communities.html', communities=communities, search=search_param, title=_('Not Joined Communities'),
SUBSCRIPTION_PENDING=SUBSCRIPTION_PENDING, SUBSCRIPTION_MEMBER=SUBSCRIPTION_MEMBER, SUBSCRIPTION_PENDING=SUBSCRIPTION_PENDING, SUBSCRIPTION_MEMBER=SUBSCRIPTION_MEMBER,

View file

@ -710,6 +710,7 @@ class User(UserMixin, db.Model):
cover = db.relationship('File', lazy='joined', foreign_keys=[cover_id], single_parent=True, cascade="all, delete-orphan") cover = db.relationship('File', lazy='joined', foreign_keys=[cover_id], single_parent=True, cascade="all, delete-orphan")
instance = db.relationship('Instance', lazy='joined', foreign_keys=[instance_id]) instance = db.relationship('Instance', lazy='joined', foreign_keys=[instance_id])
conversations = db.relationship('Conversation', lazy='dynamic', secondary=conversation_member, backref=db.backref('members', lazy='joined')) conversations = db.relationship('Conversation', lazy='dynamic', secondary=conversation_member, backref=db.backref('members', lazy='joined'))
user_notes = db.relationship('UserNote', lazy='dynamic', foreign_keys="UserNote.target_id")
ap_id = db.Column(db.String(255), index=True) # e.g. username@server ap_id = db.Column(db.String(255), index=True) # e.g. username@server
ap_profile_id = db.Column(db.String(255), index=True, unique=True) # e.g. https://server/u/username ap_profile_id = db.Column(db.String(255), index=True, unique=True) # e.g. https://server/u/username
@ -1022,6 +1023,7 @@ class User(UserMixin, db.Model):
db.session.query(PostBookmark).filter(PostBookmark.user_id == self.id).delete() db.session.query(PostBookmark).filter(PostBookmark.user_id == self.id).delete()
db.session.query(PostReplyBookmark).filter(PostReplyBookmark.user_id == self.id).delete() db.session.query(PostReplyBookmark).filter(PostReplyBookmark.user_id == self.id).delete()
db.session.query(ModLog).filter(ModLog.user_id == self.id).delete() db.session.query(ModLog).filter(ModLog.user_id == self.id).delete()
db.session.query(UserNote).filter(or_(UserNote.user_id == self.id, UserNote.target_id == self.id)).delete()
def purge_content(self, soft=True): def purge_content(self, soft=True):
files = File.query.join(Post).filter(Post.user_id == self.id).all() files = File.query.join(Post).filter(Post.user_id == self.id).all()
@ -1078,6 +1080,13 @@ class User(UserMixin, db.Model):
def has_read_post(self, post): def has_read_post(self, post):
return self.read_post.filter(read_posts.c.read_post_id == post.id).count() > 0 return self.read_post.filter(read_posts.c.read_post_id == post.id).count() > 0
@cache.memoize(timeout=500)
def get_note(self, by_user):
user_note = self.user_notes.filter(UserNote.target_id == self.id, UserNote.user_id == by_user.id).first()
if user_note:
return user_note.body
else:
return None
class ActivityLog(db.Model): class ActivityLog(db.Model):
@ -1364,7 +1373,7 @@ class Post(db.Model):
i += 1 i += 1
db.session.commit() db.session.commit()
if post.image_id: if post.image_id and not post.type == constants.POST_TYPE_VIDEO:
make_image_sizes(post.image_id, 170, 512, 'posts', make_image_sizes(post.image_id, 170, 512, 'posts',
community.low_quality) # the 512 sized image is for masonry view community.low_quality) # the 512 sized image is for masonry view

View file

@ -39,7 +39,7 @@ document.addEventListener("DOMContentLoaded", function () {
function setupPostTeaserHandler() { function setupPostTeaserHandler() {
document.querySelectorAll('.post_teaser_clickable').forEach(div => { document.querySelectorAll('.post_teaser_clickable').forEach(div => {
div.onclick = function() { div.onclick = function() {
const firstAnchor = this.parentElement.querySelector('a'); const firstAnchor = this.parentElement.querySelector('h3 a');
if (firstAnchor) { if (firstAnchor) {
window.location.href = firstAnchor.href; window.location.href = firstAnchor.href;
} }

View file

@ -6,7 +6,6 @@
<a href="{{ url_for('admin.admin_communities') }}">{{ _('Communities') }}</a> | <a href="{{ url_for('admin.admin_communities') }}">{{ _('Communities') }}</a> |
<a href="{{ url_for('admin.admin_topics') }}">{{ _('Topics') }}</a> | <a href="{{ url_for('admin.admin_topics') }}">{{ _('Topics') }}</a> |
<a href="{{ url_for('admin.admin_users', local_remote='local') }}">{{ _('Users') }}</a> | <a href="{{ url_for('admin.admin_users', local_remote='local') }}">{{ _('Users') }}</a> |
<a href="{{ url_for('admin.admin_users_trash', local_remote='local') }}">{{ _('Watch') }}</a> |
{% if site.registration_mode == 'RequireApplication' %} {% if site.registration_mode == 'RequireApplication' %}
<a href="{{ url_for('admin.admin_approve_registrations') }}">{{ _('Registration applications') }}</a> | <a href="{{ url_for('admin.admin_approve_registrations') }}">{{ _('Registration applications') }}</a> |
{% endif %} {% endif %}
@ -15,6 +14,7 @@
<a href="{{ url_for('admin.newsletter') }}">{{ _('Newsletter') }}</a> | <a href="{{ url_for('admin.newsletter') }}">{{ _('Newsletter') }}</a> |
<a href="{{ url_for('admin.admin_permissions') }}">{{ _('Permissions') }}</a> | <a href="{{ url_for('admin.admin_permissions') }}">{{ _('Permissions') }}</a> |
<a href="{{ url_for('admin.admin_activities') }}">{{ _('Activities') }}</a> <a href="{{ url_for('admin.admin_activities') }}">{{ _('Activities') }}</a>
<a href="{{ url_for('main.modlog') }}">{{ _('Modlog') }}</a>
{% if debug_mode %} {% if debug_mode %}
| <a href="{{ url_for('dev.tools') }}">{{ _('Dev Tools') }}</a> | <a href="{{ url_for('dev.tools') }}">{{ _('Dev Tools') }}</a>
{% endif%} {% endif%}

View file

@ -11,44 +11,84 @@
<div class="col"> <div class="col">
<h1>{{ _('Users') }}</h1> <h1>{{ _('Users') }}</h1>
<a class="btn btn-primary" href="{{ url_for('admin.admin_users_add') }}" style="float: right;">{{ _('Add local user') }}</a> <a class="btn btn-primary" href="{{ url_for('admin.admin_users_add') }}" style="float: right;">{{ _('Add local user') }}</a>
<form method="get"> <form id="searchUsers" method="get">
<input type="search" name="search" value="{{ search }}"> <div>
<input type="radio" name="local_remote" value="local" id="local_remote_local" {{ 'checked' if local_remote == 'local' }}><label for="local_remote_local"> Local</label> <input type="search" name="search" placeholder="{{ _('Search') }}" value="{{ search }}">
<input type="radio" name="local_remote" value="remote" id="local_remote_remote" {{ 'checked' if local_remote == 'remote' }}><label for="local_remote_remote"> Remote</label> <input type="submit" name="submit_search" value="{{ _('Search') }}" class="btn btn-primary">
<input type="submit" name="submit" value="Search" class="btn btn-primary"> </div>
<div style="display:inline;">
<label for="local_remote">Local/Remote: </label>
<select name="local_remote" class="form-control-sm submit_on_change">
<option value="">All</option>
<option value="local" {{ 'selected' if local_remote == 'local' }}>Local</option>
<option value="remote" {{ 'selected' if local_remote == 'remote' }}>Remote</option>
</select>
</div>
<div style="display:inline;">
<label for="last_seen">Active: </label>
<select name="last_seen" class="form-control-sm submit_on_change">
<option value="0">All</option>
<option value="7" {{ 'selected' if last_seen == 7 }}>7 days</option>
<option value="30" {{ 'selected' if last_seen == 30 }}>30 days</option>
</select>
</div>
<input type="hidden" name="sort_by" value="{{ sort_by }}"></button>
</form> </form>
<table class="table table-striped mt-1"> <table class="table table-striped mt-1">
<tr> <tr>
<th title="{{ _('Display name.') }}">{{ _('Name') }}</th> <th>
<th title="{{ _('Last seen.') }}">{{ _('Seen') }}</th> <button form="searchUsers" name="sort_by_btn" value="user_name{{' DESC' if sort_by == 'user_name ASC' else ' ASC' }}" class="btn" title="{{ _('Display name.') }}">
<th title="{{ _('Attitude: Percentage of up votes vs. down votes the account made.') }}">{{ _('Attitude') }}</th> {{ _('Name') }}
<th title="{{ _('Reputation: The Karma of the account. Total up votes minus down votes they got.') }}">{{ _('Reputation') }}</th> <span class="{{ 'fe fe-chevron-up' if sort_by == 'user_name DESC' }}{{ 'fe fe-chevron-down' if sort_by == 'user_name ASC' }}"></span>
</button>
</th>
<th>{{ _('Banned') }}</th> <th>{{ _('Banned') }}</th>
<th title="{{ _('How often a user has been reported.') }}">{{ _('Reports') }}</th> <th>
<th title="{{ _('IP address of last interaction.') }}">{{ _('IP and country code') }}</th> <button form="searchUsers" name="sort_by_btn" value="reports{{' ASC' if sort_by == 'reports DESC' else ' DESC' }}" class="btn" title="{{ _('How often a user has been reported.') }}">
<th title="{{ _('Which website linked to PieFed when the user initially registered.') }}">{{ _('Source') }}</th> {{ _('Reports') }}
<span class="{{ 'fe fe-chevron-up' if sort_by == 'reports ASC' }}{{ 'fe fe-chevron-down' if sort_by == 'reports DESC' }}"></span>
</button>
</th>
<th>
<button form="searchUsers" name="sort_by_btn" value="attitude{{' ASC' if sort_by == 'attitude DESC' else ' DESC' }}" class="btn" title="{{ _('Attitude: Percentage of up votes vs. down votes the account made.') }}">
{{ _('Attitude') }}
<span class="{{ 'fe fe-chevron-up' if sort_by == 'attitude ASC' }}{{ 'fe fe-chevron-down' if sort_by == 'attitude DESC' }}"></span>
</button>
</th>
<th>
<button form="searchUsers" name="sort_by_btn" value="reputation{{' ASC' if sort_by == 'reputation DESC' else ' DESC' }}" class="btn" title="{{ _('Reputation: The Karma of the account. Total up votes minus down votes they got.') }}">
{{ _('Reputation') }}
<span class="{{ 'fe fe-chevron-up' if sort_by == 'reputation ASC' }}{{ 'fe fe-chevron-down' if sort_by == 'reputation DESC' }}"></span>
</button>
</th>
<th>
<button form="searchUsers" name="sort_by_btn" value="last_seen{{' ASC' if sort_by == 'last_seen DESC' else ' DESC' }}" class="btn" title="{{ _('Last seen.') }}">
{{ _('Seen') }}
<span class="{{ 'fe fe-chevron-up' if sort_by == 'last_seen ASC' }}{{ 'fe fe-chevron-down' if sort_by == 'last_seen DESC' }}"></span>
</button>
</th>
<th>{{ _('Actions') }}</th> <th>{{ _('Actions') }}</th>
</tr> </tr>
{% for user in users.items %} {% for user in users.items %}
<tr> <tr>
<td>{{ render_username(user, add_domain=False) }}<br /> <td>{{ render_username(user, add_domain=False) }}<br />
<a href="/u/{{ user.link() }}">{{ user.user_name }}</a>{% if not user.is_local() %}<wbr />@<a href="{{ user.ap_profile_id }}">{{ user.ap_domain }}</a>{% endif %}</td> <a href="/u/{{ user.link() }}">{{ user.user_name }}</a>{% if not user.is_local() %}<wbr />@<a href="{{ user.ap_profile_id }}">{{ user.ap_domain }}</a>{% endif %}</td>
<td>{% if request.args.get('local_remote', '') == 'local' %}
{{ arrow.get(user.last_seen).humanize(locale=locale) }}
{% else %}
{{ user.last_seen }}
{% endif %}
</td>
<td>{% if user.attitude != 1 %}{{ (user.attitude * 100) | round | int }}%{% endif %}</td>
<td>{% if user.reputation %}R {{ user.reputation | round | int }}{% endif %}</td>
<td>{{ '<span class="red">Banned</span>'|safe if user.banned }} <td>{{ '<span class="red">Banned</span>'|safe if user.banned }}
{{ '<span class="red">Banned posts</span>'|safe if user.ban_posts }} {{ '<span class="red">Banned posts</span>'|safe if user.ban_posts }}
{{ '<span class="red">Banned comments</span>'|safe if user.ban_comments }}</td> {{ '<span class="red">Banned comments</span>'|safe if user.ban_comments }}</td>
<td>{{ user.reports if user.reports > 0 }} </td> <td>{{ user.reports if user.reports > 0 }} </td>
<td>{{ user.ip_address if user.ip_address }}<br />{{ user.ip_address_country if user.ip_address_country }}</td> <td>{% if user.attitude != 1 %}{{ (user.attitude * 100) | round | int }}%{% endif %}</td>
<td>{{ user.referrer if user.referrer }} </td> <td>{% if user.reputation %}R {{ user.reputation | round | int }}{% endif %}</td>
<td><a href="{{ url_for('admin.admin_user_edit', user_id=user.id) }}">Edit</a> | <td><span title="{{ user.last_seen }}">{{ arrow.get(user.last_seen).humanize(locale=locale) }}</span></td>
<a href="{{ url_for('admin.admin_user_delete', user_id=user.id) }}" class="confirm_first">Delete</a> <td><a href="{{ url_for('admin.admin_user_edit', user_id=user.id) }}">Edit</a>,
<a href="{{ url_for('admin.admin_user_delete', user_id=user.id) }}" class="confirm_first">Delete</a>,
<br />
{% if user.banned %}
<a href="{{ url_for('user.unban_profile', actor=user.link()) }}" class="confirm_first">Ban</a>,
{% else %}
<a href="{{ url_for('user.ban_profile', actor=user.link()) }}" class="confirm_first">Ban</a>,
{% endif %}
<a href="{{ url_for('user.ban_purge_profile', actor=user.link()) }}" class="confirm_first">Purge</a>
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}

View file

@ -1,77 +0,0 @@
{% if theme() and file_exists('app/templates/themes/' + theme() + '/base.html') %}
{% extends 'themes/' + theme() + '/base.html' %}
{% else %}
{% extends "base.html" %}
{% endif %}
{% from 'bootstrap/form.html' import render_form %}
{% set active_child = 'admin_users_trash' %}
{% block app_content %}
<div class="row">
<div class="col">
<h1>{{ _('Users') }}</h1>
<a class="btn btn-primary" href="{{ url_for('admin.admin_users_add') }}" style="float: right;">{{ _('Add local user') }}</a>
<form method="get">
<input type="search" name="search" value="{{ search }}">
<input type="radio" name="local_remote" value="local" id="local_remote_local" {{ 'checked' if local_remote == 'local' }}><label for="local_remote_local"> Local</label>
<input type="radio" name="local_remote" value="remote" id="local_remote_remote" {{ 'checked' if local_remote == 'remote' }}><label for="local_remote_remote"> Remote</label>
<input type="radio" name="type" value="bad_rep" id="type_bad_rep" {{ 'checked' if type == 'bad_rep' }}><label for="type_bad_rep"> Bad rep</label>
<input type="radio" name="type" value="bad_attitude" id="type_bad_attitude" {{ 'checked' if type == 'bad_attitude' }}><label for="type_bad_attitude"> Bad attitude</label>
<input type="submit" name="submit" value="Search" class="btn btn-primary">
</form>
<table class="table table-striped mt-1">
<tr>
<th>Name</th>
<th>Seen</th>
<th>Attitude</th>
<th>Rep</th>
<th>Banned</th>
<th>Reports</th>
<th>IP</th>
<th>Source</th>
<th>Actions</th>
</tr>
{% for user in users.items %}
<tr>
<td>{{ render_username(user, add_domain=False) }}<br />
<a href="/u/{{ user.link() }}">{{ user.user_name }}</a>{% if not user.is_local() %}<wbr />@<a href="{{ user.ap_profile_id }}">{{ user.ap_domain }}</a>{% endif %}</td>
<td>{% if request.args.get('local_remote', '') == 'local' %}
{{ arrow.get(user.last_seen).humanize(locale=locale) }}
{% else %}
{{ user.last_seen }}
{% endif %}
</td>
<td>{% if user.attitude != 1 %}{{ (user.attitude * 100) | round | int }}%{% endif %}</td>
<td>{% if user.reputation %}R {{ user.reputation | round | int }}{% endif %}</td>
<td>{{ '<span class="red">Banned</span>'|safe if user.banned }} </td>
<td>{{ user.reports if user.reports > 0 }} </td>
<td>{{ user.ip_address if user.ip_address }} </td>
<td>{{ user.referrer if user.referrer }} </td>
<td><a href="{{ url_for('admin.admin_user_edit', user_id=user.id) }}">Edit</a> |
<a href="{{ url_for('admin.admin_user_delete', user_id=user.id) }}" class="confirm_first">Delete</a>
</td>
</tr>
{% endfor %}
</table>
<nav aria-label="Pagination" class="mt-4" role="navigation">
{% if prev_url %}
<a href="{{ prev_url }}" class="btn btn-primary">
<span aria-hidden="true">&larr;</span> {{ _('Previous page') }}
</a>
{% endif %}
{% if next_url %}
<a href="{{ next_url }}" class="btn btn-primary">
{{ _('Next page') }} <span aria-hidden="true">&rarr;</span>
</a>
{% endif %}
</nav>
</div>
</div>
<hr />
<div class="row">
<div class="col">
{% include 'admin/_nav.html' %}
</div>
</div>
<hr />
{% endblock %}

View file

@ -19,7 +19,9 @@
<div class="card-title text-center">{{ _('Create new account') }}</div> <div class="card-title text-center">{{ _('Create new account') }}</div>
{{ render_form(form) }} {{ render_form(form) }}
{% else %} {% else %}
{{ _('Registration is closed. Only admins can create accounts.') }} <p>{{ _('Registration is closed. Only admins can create accounts.') }}</p>
<p>{{ _('If you would like to sign up for PieFed, choose one of the other instances in our network:') }}</p>
<p class="text-center"><a class="btn btn-primary" href="https://join.piefed.social/try/" title="{{ _('List of open PieFed instances') }}">{{ _('Try PieFed') }}</a></p>
{% endif %} {% endif %}
</div> </div>
</div> </div>

View file

@ -27,6 +27,12 @@
<span class="fe fe-warning orangered" title="Low reputation."> </span> <span class="fe fe-warning orangered" title="Low reputation."> </span>
{% endif -%} {% endif -%}
{% endif -%} {% endif -%}
{% if current_user.is_authenticated -%}
{% set user_note = user.get_note(current_user) %}
{% if user_note -%}
<span class="user_note" title="{{ _('User note: %(note)s', note=user_note) }}">[{{ user_note | truncate(12, True) }}]</span>
{% endif -%}
{% endif -%}
{% endif -%} {% endif -%}
</span> </span>
{% endmacro -%} {% endmacro -%}
@ -217,7 +223,6 @@
<li><a class="dropdown-item{{ ' active' if active_child == 'admin_communities' }}" href="{{ url_for('admin.admin_communities') }}">{{ _('Communities') }}</a></li> <li><a class="dropdown-item{{ ' active' if active_child == 'admin_communities' }}" href="{{ url_for('admin.admin_communities') }}">{{ _('Communities') }}</a></li>
<li><a class="dropdown-item{{ ' active' if active_child == 'admin_topics' }}" href="{{ url_for('admin.admin_topics') }}">{{ _('Topics') }}</a></li> <li><a class="dropdown-item{{ ' active' if active_child == 'admin_topics' }}" href="{{ url_for('admin.admin_topics') }}">{{ _('Topics') }}</a></li>
<li><a class="dropdown-item{{ ' active' if active_child == 'admin_users' }}" href="{{ url_for('admin.admin_users', local_remote='local') }}">{{ _('Users') }}</a></li> <li><a class="dropdown-item{{ ' active' if active_child == 'admin_users' }}" href="{{ url_for('admin.admin_users', local_remote='local') }}">{{ _('Users') }}</a></li>
<li><a class="dropdown-item{{ ' active' if active_child == 'admin_users_trash' }}" href="{{ url_for('admin.admin_users_trash', local_remote='local') }}">{{ _('Monitoring - users') }}</a></li>
<li><a class="dropdown-item{{ ' active' if active_child == 'admin_content_trash' }}" href="{{ url_for('admin.admin_content_trash') }}">{{ _('Monitoring - content') }}</a></li> <li><a class="dropdown-item{{ ' active' if active_child == 'admin_content_trash' }}" href="{{ url_for('admin.admin_content_trash') }}">{{ _('Monitoring - content') }}</a></li>
<li><a class="dropdown-item{{ ' active' if active_child == 'admin_content_spam' }}" href="{{ url_for('admin.admin_content_spam') }}">{{ _('Monitoring - spammy content') }}</a></li> <li><a class="dropdown-item{{ ' active' if active_child == 'admin_content_spam' }}" href="{{ url_for('admin.admin_content_spam') }}">{{ _('Monitoring - spammy content') }}</a></li>
<li><a class="dropdown-item{{ ' active' if active_child == 'admin_content_deleted' }}" href="{{ url_for('admin.admin_content_deleted') }}">{{ _('Deleted content') }}</a></li> <li><a class="dropdown-item{{ ' active' if active_child == 'admin_content_deleted' }}" href="{{ url_for('admin.admin_content_deleted') }}">{{ _('Deleted content') }}</a></li>
@ -228,8 +233,9 @@
<li><a class="dropdown-item{{ ' active' if active_child == 'admin_federation' }}" href="{{ url_for('admin.admin_federation') }}">{{ _('Federation') }}</a></li> <li><a class="dropdown-item{{ ' active' if active_child == 'admin_federation' }}" href="{{ url_for('admin.admin_federation') }}">{{ _('Federation') }}</a></li>
<li><a class="dropdown-item{{ ' active' if active_child == 'admin_instances' }}" href="{{ url_for('admin.admin_instances') }}">{{ _('Instances') }}</a></li> <li><a class="dropdown-item{{ ' active' if active_child == 'admin_instances' }}" href="{{ url_for('admin.admin_instances') }}">{{ _('Instances') }}</a></li>
<li><a class="dropdown-item{{ ' active' if active_child == 'admin_newsletter' }}" href="{{ url_for('admin.newsletter') }}">{{ _('Newsletter') }}</a></li> <li><a class="dropdown-item{{ ' active' if active_child == 'admin_newsletter' }}" href="{{ url_for('admin.newsletter') }}">{{ _('Newsletter') }}</a></li>
<li><a class="dropdown-item{{ ' active' if active_child == 'admin_activities' }}" href="{{ url_for('admin.admin_activities') }}">{{ _('Activities') }}</a></li>
<li><a class="dropdown-item{{ ' active' if active_child == 'admin_permissions' }}" href="{{ url_for('admin.admin_permissions') }}">{{ _('Permissions') }}</a></li> <li><a class="dropdown-item{{ ' active' if active_child == 'admin_permissions' }}" href="{{ url_for('admin.admin_permissions') }}">{{ _('Permissions') }}</a></li>
<li><a class="dropdown-item{{ ' active' if active_child == 'admin_activities' }}" href="{{ url_for('admin.admin_activities') }}">{{ _('Activities') }}</a></li>
<li><a class="dropdown-item{{ ' active' if active_child == 'modlog' }}" href="{{ url_for('main.modlog') }}">{{ _('Modlog') }}</a></li>
{% if debug_mode %} {% if debug_mode %}
<li><a class="dropdown-item{{ ' active' if active_child == 'dev_tools' }}" href="{{ url_for('dev.tools') }}">{{ _('Dev Tools') }}</a></li> <li><a class="dropdown-item{{ ' active' if active_child == 'dev_tools' }}" href="{{ url_for('dev.tools') }}">{{ _('Dev Tools') }}</a></li>
{% endif %} {% endif %}

View file

@ -15,12 +15,12 @@
{% include "_home_nav.html" %} {% include "_home_nav.html" %}
{% include "_view_filter_nav.html" %} {% include "_view_filter_nav.html" %}
<div class="post_list h-feed"> <div class="post_list h-feed">
{% for post in posts.items -%} {% for post in posts %}
{% include 'post/_post_teaser.html' %} {% include 'post/_post_teaser.html' %}
{% else -%} {% else %}
<p>{{ _('No posts yet. Join some communities to see more.') }}</p> <p>{{ _('No posts yet. Join some communities to see more.') }}</p>
<p><a class="btn btn-primary" href="/communities">{{ _('More communities') }}</a></p> <p><a class="btn btn-primary" href="/communities">{{ _('More communities') }}</a></p>
{% endfor -%} {% endfor %}
</div> </div>
<nav aria-label="Pagination" class="mt-4" role="navigation"> <nav aria-label="Pagination" class="mt-4" role="navigation">

View file

@ -27,27 +27,46 @@
</div> </div>
</div> </div>
<div class="col-auto"> <div class="col-auto">
{% if topics -%} <form
<form method="get" style="display:inline;">Topic: id="searchCommunities"
<select name="topic_id" class="form-control-sm submit_on_change" aria-label="{{ _('Choose a topic to filter communities by') }}"> method="get"
<option value="0">All</option> >
{% for topic in topics -%} {% if topics -%}
<option value="{{ topic.id }}" {{ 'selected' if topic.id == topic_id }}>{{ topic.name }}</option> <div style="display:inline;">
{% endfor -%} Topic:
</select> <select name="topic_id"
</form> class="form-control-sm submit_on_change"
{% endif -%} aria-label="{{ _('Choose a topic to filter communities by') }}"
{% if languages -%} >
<form method="get" style="display:inline;">Language: <option value="0">All</option>
<select name="language_id" class="form-control-sm submit_on_change" aria-label="{{ _('Choose a language to filter communities by') }}"> {% for topic in topics -%}
<option value="0">All</option> <option value="{{ topic.id }}" {{ 'selected' if topic.id == topic_id }}>{{ topic.name }}
{% for language in languages -%} </option>
<option value="{{ language.id }}" {{ 'selected' if language.id == language_id }}>{{ language.name }}</option> {% endfor -%}
{% endfor -%} </select>
</select> </div>
</form> {% endif -%}
{% endif -%} {% if languages -%}
<form method="get" style="display:inline;"><input type="search" name="search" placeholder="{{ _('Search') }}" value="{{ search }}"></form> <div style="display:inline;">
Language:
<select name="language_id"
class="form-control-sm submit_on_change"
aria-label="{{ _('Choose a language to filter communities by') }}">
<option value="0">All</option>
{% for language in languages -%}
<option value="{{ language.id }}" {{ 'selected' if language.id == language_id }}>{{ language.name }}
</option>
{% endfor -%}
</select>
</div>
{% endif -%}
<div style="display:inline;">
<input type="search"
name="search"
placeholder="{{ _('Search') }}"
value="{{ search }}">
</div>
</form>
</div> </div>
<div class="col-auto"> <div class="col-auto">
<div class="btn-group"> <div class="btn-group">
@ -67,24 +86,56 @@
<tr> <tr>
<th> </th> <th> </th>
<th {% if not low_bandwidth -%}colspan="2"{% endif -%} scope="col"> <th {% if not low_bandwidth -%}colspan="2"{% endif -%} scope="col">
<a href="?sort_by=title{{ ' asc' if sort_by == 'title desc' else ' desc' }}" title="{{ _('Sort by name') }}" rel="nofollow">{{ _('Community') }} <button
form="searchCommunities"
hx-boost="true"
name="sort_by"
value="title{{ ' asc' if sort_by == 'title desc' else ' desc' }}"
title="{{ _('Sort by name') }}"
class="btn"
>
{{ _('Community') }}
<span class="{{ 'fe fe-chevron-up' if sort_by == 'title asc' }}{{ 'fe fe-chevron-down' if sort_by == 'title desc' }}"></span> <span class="{{ 'fe fe-chevron-up' if sort_by == 'title asc' }}{{ 'fe fe-chevron-down' if sort_by == 'title desc' }}"></span>
</a> </button>
</th> </th>
<th scope="col"> <th scope="col">
<a href="?sort_by=post_count{{ ' asc' if sort_by == 'post_count desc' else ' desc' }}" title="{{ _('Sort by post count') }}" rel="nofollow">{{ _('Posts') }} <button
form="searchCommunities"
hx-boost="true"
name="sort_by"
value="post_count{{ ' asc' if sort_by == 'post_count desc' else ' desc' }}"
title="{{ _('Sort by post count') }}"
class="btn"
>
{{ _('Posts') }}
<span class="{{ 'fe fe-chevron-up' if sort_by == 'post_count asc' }}{{ 'fe fe-chevron-down' if sort_by == 'post_count desc' }}"></span> <span class="{{ 'fe fe-chevron-up' if sort_by == 'post_count asc' }}{{ 'fe fe-chevron-down' if sort_by == 'post_count desc' }}"></span>
</a> </button>
</th> </th>
<th scope="col"> <th scope="col">
<a href="?sort_by=post_reply_count{{ ' asc' if sort_by == 'post_reply_count desc' else ' desc' }}" title="{{ _('Sort by reply count') }}" rel="nofollow">{{ _('Comments') }} <button
form="searchCommunities"
hx-boost="true"
name="sort_by"
value="post_reply_count{{ ' asc' if sort_by == 'post_reply_count desc' else ' desc' }}"
title="{{ _('Comments') }}"
class="btn"
>
{{ _('Comments') }}
<span class="{{ 'fe fe-chevron-up' if sort_by == 'post_reply_count asc' }}{{ 'fe fe-chevron-down' if sort_by == 'post_reply_count desc' }}"></span> <span class="{{ 'fe fe-chevron-up' if sort_by == 'post_reply_count asc' }}{{ 'fe fe-chevron-down' if sort_by == 'post_reply_count desc' }}"></span>
</a> </button>
</th> </th>
<th scope="col"> <th scope="col">
<a href="?sort_by=last_active{{ ' asc' if sort_by == 'last_active desc' else ' desc' }}" title="{{ _('Sort by recent activity') }}" rel="nofollow">{{ _('Active') }} <button
form="searchCommunities"
hx-boost="true"
name="sort_by"
value="last_active{{ ' asc' if sort_by == 'last_active desc' else ' desc' }}"
title="{{ _('Sort by recent activity') }}"
class="btn"
>
{{ _('Active') }}
<span class="{{ 'fe fe-chevron-up' if sort_by == 'last_active asc' }}{{ 'fe fe-chevron-down' if sort_by == 'last_active desc' }}"></span> <span class="{{ 'fe fe-chevron-up' if sort_by == 'last_active asc' }}{{ 'fe fe-chevron-down' if sort_by == 'last_active desc' }}"></span>
</a> </button>
</th> </th>
</tr> </tr>
</thead> </thead>

View file

@ -4,7 +4,7 @@
{% extends "base.html" -%} {% extends "base.html" -%}
{% endif -%} -%} {% endif -%} -%}
{% from 'bootstrap5/form.html' import render_form -%} {% from 'bootstrap5/form.html' import render_form -%}
{% set active_child = 'list_communities' -%} {% set active_child = 'modlog' -%}
{% block app_content -%} {% block app_content -%}
<h1>{{ _('Moderation log') }}</h1> <h1>{{ _('Moderation log') }}</h1>

View file

@ -0,0 +1,68 @@
{% if theme() and file_exists('app/templates/themes/' + theme() + '/base.html') -%}
{% extends 'themes/' + theme() + '/base.html' %}
{% else -%}
{% extends "base.html" %}
{% endif -%}
{% from 'bootstrap/form.html' import render_form %}
{% block app_content %}
<div class="row">
<div class="col col-login mx-auto">
<div class="card mt-5">
<div class="card-body p-6">
<div class="card-title">{{ _('Edit note for "%(user_name)s"', user_name=user.display_name()) }}</div>
<div class="card-body">
<strong>{{ _('Emoji quick access') }}</strong>
<div>
<button id="thumbsup" class="emojitoggle">👍</button>
<button id="thumbsdown" class="emojitoggle">👎</button>
<button id="smile" class="emojitoggle">😄</button>
<button id="party-popper" class="emojitoggle">🎉</button>
<button id="frown" class="emojitoggle">😕</button>
<button id="red-heart" class="emojitoggle">❤️</button>
<button id="rocket" class="emojitoggle">🚀</button>
<button id="eyes" class="emojitoggle">👀</button>
</div><div>
<button id="star" class="emojitoggle"></button>
<button id="medal" class="emojitoggle">🥇</button>
<button id="check" class="emojitoggle">☑️</button>
<button id="fire" class="emojitoggle">🔥</button>
<button id="robot" class="emojitoggle">🤖</button>
<button id="ghost" class="emojitoggle">👻</button>
<button id="clown" class="emojitoggle">🤡</button>
<button id="poo" class="emojitoggle">💩</button>
</div><div>
<button id="speech-bubble" class="emojitoggle">💬</button>
<button id="anger-bubble" class="emojitoggle">🗯️</button>
<button id="hundred" class="emojitoggle">💯</button>
<button id="rofl" class="emojitoggle">🤣</button>
<button id="zany" class="emojitoggle">🤪</button>
<button id="warning" class="emojitoggle">⚠️</button>
<button id="no-entry" class="emojitoggle"></button>
<button id="vomit" class="emojitoggle">🤮</button>
</div>
{{ render_form(form) }}
<div class="row mt-5"><small class="field_hint">{{ _('This note appears next to their username. It\'s meant just for you and not displayed to anyone else.') }}</small></div>
</div>
</div>
</div>
</div>
</div>
<script nonce="{{ session['nonce'] }}" type="text/javascript">
function addtext(text) {
var note = document.getElementById("note");
newtext = note.value.replaceAll(text, "");
if (newtext == note.value) {
note.value += text;
} else {
note.value = newtext;
}
}
document.addEventListener("DOMContentLoaded", function() {
document.querySelectorAll("button.emojitoggle").forEach(function(button) {
var emoji = button.textContent || button.innerText;
button.addEventListener('click', function() {addtext(emoji);});
});
});
</script>
{% endblock %}

View file

@ -100,6 +100,7 @@
{% endif -%} {% endif -%}
{% endif -%} {% endif -%}
<li><a class="dropdown-item" href="{{ url_for('user.report_profile', actor=user.link()) }}" rel="nofollow">{{ _('Report') }}</a></li> <li><a class="dropdown-item" href="{{ url_for('user.report_profile', actor=user.link()) }}" rel="nofollow">{{ _('Report') }}</a></li>
<li><a class="dropdown-item" href="{{ url_for('user.edit_user_note', actor=user.link()) }}" rel="nofollow">{{ _('Edit note') }}</a></li>
</ul> </ul>
</div> </div>
{% endif %} {% endif %}
@ -108,6 +109,8 @@
{% if user.is_instance_admin() or (user.is_local() and user.is_admin()) %}<span class="red">({{ _('Admin') }})</span>{% endif %}<br /> {% if user.is_instance_admin() or (user.is_local() and user.is_admin()) %}<span class="red">({{ _('Admin') }})</span>{% endif %}<br />
{% if user.is_admin() or user.is_staff() %}{{ _('Role permissions') }}: {% if user.is_admin() %}{{ _('Admin') }}{% endif %} {% if user.is_staff() %}{{ _('Staff') }}{% endif %}<br />{% endif %} {% if user.is_admin() or user.is_staff() %}{{ _('Role permissions') }}: {% if user.is_admin() %}{{ _('Admin') }}{% endif %} {% if user.is_staff() %}{{ _('Staff') }}{% endif %}<br />{% endif %}
{{ _('Joined') }}: {{ arrow.get(user.created).humanize(locale=locale) }}<br /> {{ _('Joined') }}: {{ arrow.get(user.created).humanize(locale=locale) }}<br />
{% if current_user.is_authenticated and current_user.is_admin() %}{{ _('Referer') }}: <span title="{{ _('Which website linked to PieFed when the user initially registered.') }}">{{ user.referrer if user.referrer }}</span><br />{% endif %}
{% if current_user.is_authenticated and current_user.is_admin() %}{{ _('IP and country code') }}: <span title="{{ _('IP address of last interaction.') }}">{{ user.ip_address if user.ip_address }}{% if user.ip_address_country %} ({{ user.ip_address_country }}){% endif %}</span><br />{% endif %}
{% if current_user.is_authenticated and current_user.is_admin() and user.last_seen %}{{ _('Active') }}: {{ arrow.get(user.last_seen).humanize(locale=locale) }}<br />{% endif %} {% if current_user.is_authenticated and current_user.is_admin() and user.last_seen %}{{ _('Active') }}: {{ arrow.get(user.last_seen).humanize(locale=locale) }}<br />{% endif %}
{% if user.bot %} {% if user.bot %}
{{ _('Bot Account') }}<br /> {{ _('Bot Account') }}<br />
@ -116,6 +119,7 @@
{% if current_user.is_authenticated and current_user.is_admin() and user.reputation %}{{ _('Reputation') }}: <span title="{{ _('Reputation: The Karma of the account. Total up votes minus down votes they got.') }}">{{ user.reputation | round | int }}</span><br />{% endif %} {% if current_user.is_authenticated and current_user.is_admin() and user.reputation %}{{ _('Reputation') }}: <span title="{{ _('Reputation: The Karma of the account. Total up votes minus down votes they got.') }}">{{ user.reputation | round | int }}</span><br />{% endif %}
{{ _('Posts') }}: {{ user.post_count }}<br /> {{ _('Posts') }}: {{ user.post_count }}<br />
{{ _('Comments') }}: {{ user.post_reply_count }}<br /> {{ _('Comments') }}: {{ user.post_reply_count }}<br />
{% if current_user.is_authenticated %}{{ _('User note') }}: {{ user.get_note(current_user) }}<br />{% endif %}
</p> </p>
<div class="profile_bio"> <div class="profile_bio">
{{ user.about_html|safe }} {{ user.about_html|safe }}

View file

@ -147,3 +147,8 @@ class RemoteFollowForm(FlaskForm):
instance_type = SelectField(_l('Instance type'), choices=type_choices, render_kw={'class': 'form-select'}) instance_type = SelectField(_l('Instance type'), choices=type_choices, render_kw={'class': 'form-select'})
submit = SubmitField(_l('View profile on remote instance')) submit = SubmitField(_l('View profile on remote instance'))
class UserNoteForm(FlaskForm):
note = StringField(_l('User note'), validators=[Optional(), Length(max=50)])
submit = SubmitField(_l('Save note'))

View file

@ -16,10 +16,10 @@ from app.constants import *
from app.email import send_verification_email from app.email import send_verification_email
from app.models import Post, Community, CommunityMember, User, PostReply, PostVote, Notification, utcnow, File, Site, \ from app.models import Post, Community, CommunityMember, User, PostReply, PostVote, Notification, utcnow, File, Site, \
Instance, Report, UserBlock, CommunityBan, CommunityJoinRequest, CommunityBlock, Filter, Domain, DomainBlock, \ Instance, Report, UserBlock, CommunityBan, CommunityJoinRequest, CommunityBlock, Filter, Domain, DomainBlock, \
InstanceBlock, NotificationSubscription, PostBookmark, PostReplyBookmark, read_posts, Topic InstanceBlock, NotificationSubscription, PostBookmark, PostReplyBookmark, read_posts, Topic, UserNote
from app.user import bp from app.user import bp
from app.user.forms import ProfileForm, SettingsForm, DeleteAccountForm, ReportUserForm, \ from app.user.forms import ProfileForm, SettingsForm, DeleteAccountForm, ReportUserForm, \
FilterForm, KeywordFilterEditForm, RemoteFollowForm, ImportExportForm FilterForm, KeywordFilterEditForm, RemoteFollowForm, ImportExportForm, UserNoteForm
from app.user.utils import purge_user_then_delete, unsubscribe_from_community from app.user.utils import purge_user_then_delete, unsubscribe_from_community
from app.utils import get_setting, render_template, markdown_to_html, user_access, markdown_to_text, shorten_string, \ from app.utils import get_setting, render_template, markdown_to_html, user_access, markdown_to_text, shorten_string, \
is_image_url, ensure_directory_exists, gibberish, file_get_contents, community_membership, user_filters_home, \ is_image_url, ensure_directory_exists, gibberish, file_get_contents, community_membership, user_filters_home, \
@ -1330,3 +1330,36 @@ def user_read_posts_delete():
db.session.commit() db.session.commit()
flash(_('Reading history has been deleted')) flash(_('Reading history has been deleted'))
return redirect(url_for('user.user_read_posts')) return redirect(url_for('user.user_read_posts'))
@bp.route('/u/<actor>/note', methods=['GET', 'POST'])
@login_required
def edit_user_note(actor):
actor = actor.strip()
if '@' in actor:
user: User = User.query.filter_by(ap_id=actor, deleted=False).first()
else:
user: User = User.query.filter_by(user_name=actor, deleted=False, ap_id=None).first()
if user is None:
abort(404)
form = UserNoteForm()
if form.validate_on_submit() and not current_user.banned:
text = form.note.data.strip()
usernote = UserNote.query.filter(UserNote.target_id == user.id, UserNote.user_id == current_user.id).first()
if usernote:
usernote.body = text
else:
usernote = UserNote(target_id=user.id, user_id=current_user.id, body=text)
db.session.add(usernote)
db.session.commit()
cache.delete_memoized(User.get_note, user, current_user)
flash(_('Your changes have been saved.'), 'success')
goto = request.args.get('redirect') if 'redirect' in request.args else f'/u/{actor}'
return redirect(goto)
elif request.method == 'GET':
form.note.data = user.get_note(current_user)
return render_template('user/edit_note.html', title=_('Edit note'), form=form, user=user,
menu_topics=menu_topics(), site=g.site)

View file

@ -4,7 +4,6 @@ import bisect
import hashlib import hashlib
import mimetypes import mimetypes
import random import random
import tempfile
import urllib import urllib
from collections import defaultdict from collections import defaultdict
from datetime import datetime, timedelta, date from datetime import datetime, timedelta, date
@ -13,7 +12,6 @@ from typing import List, Literal, Union
import httpx import httpx
import markdown2 import markdown2
import math
from urllib.parse import urlparse, parse_qs, urlencode from urllib.parse import urlparse, parse_qs, urlencode
from functools import wraps from functools import wraps
import flask import flask
@ -33,7 +31,6 @@ from wtforms.fields import SelectField, SelectMultipleField
from wtforms.widgets import Select, html_params, ListWidget, CheckboxInput from wtforms.widgets import Select, html_params, ListWidget, CheckboxInput
from app import db, cache, httpx_client from app import db, cache, httpx_client
import re import re
from moviepy.editor import VideoFileClip
from PIL import Image, ImageOps from PIL import Image, ImageOps
from app.models import Settings, Domain, Instance, BannedInstances, User, Community, DomainBlock, ActivityPubLog, IpBan, \ from app.models import Settings, Domain, Instance, BannedInstances, User, Community, DomainBlock, ActivityPubLog, IpBan, \
@ -1108,49 +1105,6 @@ def in_sorted_list(arr, target):
return index < len(arr) and arr[index] == target return index < len(arr) and arr[index] == target
# Makes a still image from a video url, without downloading the whole video file
def generate_image_from_video_url(video_url, output_path, length=2):
response = httpx_client.get(video_url, stream=True, timeout=5,
headers={'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64; rv:127.0) Gecko/20100101 Firefox/127.0'}) # Imgur requires a user agent
content_type = response.headers.get('Content-Type')
if content_type:
if 'video/mp4' in content_type:
temp_file_extension = '.mp4'
elif 'video/webm' in content_type:
temp_file_extension = '.webm'
else:
raise ValueError("Unsupported video format")
else:
raise ValueError("Content-Type not found in response headers")
# Generate a random temporary file name
temp_file_name = gibberish(15) + temp_file_extension
temp_file_path = os.path.join(tempfile.gettempdir(), temp_file_name)
# Write the downloaded data to a temporary file
with open(temp_file_path, 'wb') as f:
for chunk in response.iter_content(chunk_size=4096):
f.write(chunk)
if os.path.getsize(temp_file_path) >= length * 1024 * 1024:
break
# Generate thumbnail from the temporary file
try:
clip = VideoFileClip(temp_file_path)
except Exception as e:
os.unlink(temp_file_path)
raise e
thumbnail = clip.get_frame(0)
clip.close()
# Save the image
thumbnail_image = Image.fromarray(thumbnail)
thumbnail_image.save(output_path)
os.remove(temp_file_path)
@cache.memoize(timeout=600) @cache.memoize(timeout=600)
def recently_upvoted_posts(user_id) -> List[int]: def recently_upvoted_posts(user_id) -> List[int]:
post_ids = db.session.execute(text('SELECT post_id FROM "post_vote" WHERE user_id = :user_id AND effect > 0 ORDER BY id DESC LIMIT 1000'), post_ids = db.session.execute(text('SELECT post_id FROM "post_vote" WHERE user_id = :user_id AND effect > 0 ORDER BY id DESC LIMIT 1000'),

View file

@ -1,4 +1,5 @@
services: services:
db: db:
shm_size: 128mb shm_size: 128mb
image: postgres image: postgres
@ -6,37 +7,62 @@ services:
- ./.env.docker - ./.env.docker
volumes: volumes:
- ./pgdata:/var/lib/postgresql/data - ./pgdata:/var/lib/postgresql/data
networks:
- pf_network
redis: redis:
image: redis image: redis
env_file: env_file:
- ./.env.docker - ./.env.docker
networks:
- pf_network
celery: celery:
build: build:
context: . context: .
target: builder target: builder
container_name: piefed_celery1
depends_on:
- db
- redis
env_file: env_file:
- ./.env.docker - ./.env.docker
entrypoint: ./entrypoint_celery.sh entrypoint: ./entrypoint_celery.sh
volumes: volumes:
- ./media/:/app/app/static/media/ - ./media/:/app/app/static/media/
networks:
- pf_network
web: web:
build: build:
context: . context: .
target: builder target: builder
container_name: piefed_app1
depends_on: depends_on:
- db - db
- redis - redis
env_file: env_file:
- ./.env.docker - ./.env.docker
volumes: volumes:
- ./.gunicorn.conf.py:/app/gunicorn.conf.py - ./gunicorn.conf.py:/app/gunicorn.conf.py
- ./media/:/app/app/static/media/ - ./media/:/app/app/static/media/
ports: ports:
- '8080:5000' - '8030:5000'
networks:
- pf_network
adminer: adminer:
image: adminer image: adminer
restart: always restart: always
ports: ports:
- 8888:8080 - 8888:8080
depends_on:
- db
networks:
- pf_network
networks:
pf_network:
name: pf_network
external: false

0
entrypoint.sh Normal file → Executable file
View file

0
entrypoint_celery.sh Normal file → Executable file
View file

24
env.docker.sample Normal file
View file

@ -0,0 +1,24 @@
SECRET_KEY='change this to random characters'
SERVER_NAME='your_domain_here'
POSTGRES_USER='piefed'
POSTGRES_PASSWORD='piefed'
POSTGRES_DB='piefed'
DATABASE_URL=postgresql+psycopg2://piefed:piefed@db/piefed
CACHE_TYPE='RedisCache'
CACHE_REDIS_DB=1
CACHE_REDIS_URL='redis://redis:6379/0'
CELERY_BROKER_URL='redis://redis:6379/1'
MODE='production'
FULL_AP_CONTEXT=0
SPICY_UNDER_10 = 2.5
SPICY_UNDER_30 = 1.85
SPICY_UNDER_60 = 1.25

View file

@ -32,4 +32,3 @@ Werkzeug==2.3.3
pytesseract==0.3.10 pytesseract==0.3.10
sentry-sdk==1.40.6 sentry-sdk==1.40.6
python-slugify==8.0.4 python-slugify==8.0.4
moviepy==1.0.3