Merge branch 'main' into feature/image_post_editing

This commit is contained in:
mtyton 2024-12-21 13:13:07 +01:00
commit dae5441604
24 changed files with 614 additions and 474 deletions

View file

@ -4,7 +4,7 @@ FROM --platform=$BUILDPLATFORM python:3-alpine AS builder
RUN apk update
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
COPY . /app

View file

@ -1,7 +1,8 @@
# Contents
* [Setup Database](#setup-database)
* [Install Python Libraries](#install-python-libraries)
* [Choose your path - easy way or hard way](#choose-path)
* [Setup Database](#setup-database)
* [Install Python Libraries](#install-python-libraries)
* [Install additional requirements](#install-additional-requirements)
* [Setup pyfedi](#setup-pyfedi)
* [Setup .env file](#setup-env-file)
@ -14,6 +15,110 @@
* [Notes for Windows (WSL2)](#notes-for-windows-wsl2)
* [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>
## Setup Database
@ -77,7 +182,7 @@ sudo apt install tesseract-ocr
## Setup PyFedi
* Clone PyFedi
* Clone PieFed
```bash
git clone https://codeberg.org/rimu/pyfedi.git

View file

@ -10,7 +10,6 @@ import httpx
import redis
from flask import current_app, request, g, url_for, json
from flask_babel import _
from requests import JSONDecodeError
from sqlalchemy import text, func, desc
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, \
is_image_url, domain_from_url, gibberish, ensure_directory_exists, head_request, \
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, \
html_to_text, add_to_modlog_activitypub, joined_communities, \
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()
file: File = session.query(File).get(file_id)
if file and file.source_url:
# Videos (old code. not invoked because file.source_url won't end .mp4 or .webm)
if file.source_url.endswith('.mp4') or file.source_url.endswith('.webm'):
new_filename = gibberish(15)
# 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
try:
source_image_response = get_request(file.source_url)
except:
pass
else:
try:
source_image_response = get_request(file.source_url)
except:
pass
else:
if source_image_response.status_code == 404 and '/api/v3/image_proxy' in file.source_url:
source_image_response.close()
# 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
# The un-proxied image url is the query parameter called 'url'
parsed_url = urlparse(file.source_url)
query_params = parse_qs(parsed_url.query)
if 'url' in query_params:
url_value = query_params['url'][0]
source_image_response = get_request(url_value)
else:
source_image_response = None
if source_image_response and source_image_response.status_code == 200:
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()
if source_image_response.status_code == 404 and '/api/v3/image_proxy' in file.source_url:
source_image_response.close()
# 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
# The un-proxied image url is the query parameter called 'url'
parsed_url = urlparse(file.source_url)
query_params = parse_qs(parsed_url.query)
if 'url' in query_params:
url_value = query_params['url'][0]
source_image_response = get_request(url_value)
else:
source_image_response = None
if source_image_response and source_image_response.status_code == 200:
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('/')
if content_type_parts:
# content type headers often are just 'image/jpeg' but sometimes 'image/jpeg;charset=utf8'
content_type_parts = content_type.split('/')
if content_type_parts:
# content type headers often are just 'image/jpeg' but sometimes 'image/jpeg;charset=utf8'
# Remove ;charset=whatever
main_part = content_type.split(';')[0]
# Remove ;charset=whatever
main_part = content_type.split(';')[0]
# Split the main part on the '/' character and take the second part
file_ext = '.' + main_part.split('/')[1]
file_ext = file_ext.strip() # just to be sure
# Split the main part on the '/' character and take the second part
file_ext = '.' + main_part.split('/')[1]
file_ext = file_ext.strip() # just to be sure
if file_ext == '.jpeg':
file_ext = '.jpg'
elif file_ext == '.svg+xml':
return # no need to resize SVG images
elif file_ext == '.octet-stream':
file_ext = '.avif'
else:
file_ext = os.path.splitext(file.source_url)[1]
file_ext = file_ext.replace('%3f', '?') # sometimes urls are not decoded properly
if '?' in file_ext:
file_ext = file_ext.split('?')[0]
if file_ext == '.jpeg':
file_ext = '.jpg'
elif file_ext == '.svg+xml':
return # no need to resize SVG images
elif file_ext == '.octet-stream':
file_ext = '.avif'
else:
file_ext = os.path.splitext(file.source_url)[1]
file_ext = file_ext.replace('%3f', '?') # sometimes urls are not decoded properly
if '?' in file_ext:
file_ext = file_ext.split('?')[0]
new_filename = gibberish(15)
new_filename = gibberish(15)
# set up the storage directory
directory = f'app/static/media/{directory}/' + new_filename[0:2] + '/' + new_filename[2:4]
ensure_directory_exists(directory)
# 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 + file_ext)
final_place_thumbnail = os.path.join(directory, new_filename + '_thumbnail.webp')
# file path and names to store the resized images on disk
final_place = os.path.join(directory, new_filename + file_ext)
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
import pillow_avif
if file_ext == '.avif': # this is quite a big plugin so we'll only load it if necessary
import pillow_avif
# Load image data into Pillow
Image.MAX_IMAGE_PIXELS = 89478485
image = Image.open(BytesIO(source_image))
image = ImageOps.exif_transpose(image)
img_width = image.width
img_height = image.height
# Load image data into Pillow
Image.MAX_IMAGE_PIXELS = 89478485
image = Image.open(BytesIO(source_image))
image = ImageOps.exif_transpose(image)
img_width = image.width
img_height = image.height
# 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 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
# 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()
session.commit()
# Alert regarding fascist meme content
if toxic_community and img_width < 2000: # images > 2000px tend to be real photos instead of 4chan screenshots.
try:
image_text = pytesseract.image_to_string(Image.open(BytesIO(source_image)).convert('L'), timeout=30)
except Exception as e:
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'
post = Post.query.filter_by(image_id=file.id).first()
notification = Notification(title='Review this',
user_id=1,
author_id=post.user_id,
url=url_for('activitypub.post_ap', post_id=post.id))
session.add(notification)
session.commit()
# Alert regarding fascist meme content
if toxic_community and img_width < 2000: # images > 2000px tend to be real photos instead of 4chan screenshots.
try:
image_text = pytesseract.image_to_string(Image.open(BytesIO(source_image)).convert('L'), timeout=30)
except Exception as e:
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'
post = Post.query.filter_by(image_id=file.id).first()
notification = Notification(title='Review this',
user_id=1,
author_id=post.user_id,
url=url_for('activitypub.post_ap', post_id=post.id))
session.add(notification)
session.commit()
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)
search = request.args.get('search', '')
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)
if local_remote == 'local':
users = users.filter_by(ap_id=None)
if local_remote == 'remote':
elif local_remote == 'remote':
users = users.filter(User.ap_id != None)
if 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
prev_url = url_for('admin.admin_users', page=users.prev_num) if users.has_prev and page != 1 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, 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,
local_remote=local_remote, search=search,
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,
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(),

View file

@ -116,39 +116,9 @@ def home_page(sort, view_filter):
posts = posts.order_by(desc(Post.last_active))
# Pagination
if view_filter == 'subscribed' and current_user.is_authenticated and sort == 'new':
# use python list instead of DB query
posts = posts.all()
# exclude extra cross-posts from feed
already_seen = []
limit = 100 if not low_bandwidth else 50
#i = -1 # option 1: don't exclude cross-posts
#i = min(limit - 1, len(posts) - 1) # option 2: exclude cross-posts from the first page only
i = min((limit * 10) - 1, len(posts) - 1) # option 3: exclude cross-posts across a 'magic number' of pages
#i = len(posts) - 1 # option 4: exclude all cross-posts ever
while i >= 0:
if not posts[i].cross_posts:
i -= 1
continue
if posts[i].id in already_seen:
posts.pop(i)
else:
already_seen.extend(posts[i].cross_posts)
i -= 1
# paginate manually (can't use paginate())
start = (page - 1) * limit
end = start + limit
posts = posts[start:end]
next_page = page + 1 if len(posts) == limit else None
previous_page = page - 1 if page != 1 else None
next_url = url_for('main.index', page=next_page, sort=sort, view_filter=view_filter) if next_page else None
prev_url = url_for('main.index', page=previous_page, sort=sort, view_filter=view_filter) if previous_page else None
else:
posts = posts.paginate(page=page, per_page=100 if current_user.is_authenticated and not low_bandwidth else 50, error_out=False)
next_url = url_for('main.index', page=posts.next_num, sort=sort, view_filter=view_filter) if posts.has_next else None
prev_url = url_for('main.index', page=posts.prev_num, sort=sort, view_filter=view_filter) if posts.has_prev and page != 1 else None
posts = posts.paginate(page=page, per_page=100 if current_user.is_authenticated and not low_bandwidth else 50, error_out=False)
next_url = url_for('main.index', page=posts.next_num, sort=sort, view_filter=view_filter) if posts.has_next else None
prev_url = url_for('main.index', page=posts.prev_num, sort=sort, view_filter=view_filter) if posts.has_prev and page != 1 else None
# Active Communities
active_communities = Community.query.filter_by(banned=False)
@ -250,7 +220,6 @@ def list_communities():
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,
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)
@ -302,11 +271,11 @@ def list_local_communities():
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,
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)
@bp.route('/communities/subscribed', methods=['GET'])
@login_required
def list_subscribed_communities():
verification_warning()
search_param = request.args.get('search', '')
@ -317,36 +286,39 @@ def list_subscribed_communities():
sort_by = request.args.get('sort_by', 'post_reply_count desc')
topics = Topic.query.order_by(Topic.name).all()
languages = Language.query.order_by(Language.name).all()
if current_user.is_authenticated:
communities = Community.query.filter_by(banned=False).join(CommunityMember).filter(CommunityMember.user_id == current_user.id)
# get all the communities
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
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:
communities = []
next_url = None
prev_url = None
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
return render_template('list_communities.html', communities=communities, search=search_param, title=_('Joined Communities'),
SUBSCRIPTION_PENDING=SUBSCRIPTION_PENDING, SUBSCRIPTION_MEMBER=SUBSCRIPTION_MEMBER,
@ -354,7 +326,6 @@ def list_subscribed_communities():
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,
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)
@ -369,49 +340,43 @@ def list_not_subscribed_communities():
sort_by = request.args.get('sort_by', 'post_reply_count desc')
topics = Topic.query.order_by(Topic.name).all()
languages = Language.query.order_by(Language.name).all()
if current_user.is_authenticated:
# get all communities
all_communities = Community.query.filter_by(banned=False)
# get the user's joined communities
joined_communities = Community.query.filter_by(banned=False).join(CommunityMember).filter(CommunityMember.user_id == current_user.id)
# get the joined community ids list
joined_ids = []
for jc in joined_communities:
joined_ids.append(jc.id)
# filter out the joined communities from all communities
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
# get all communities
all_communities = Community.query.filter_by(banned=False)
# get the user's joined communities
joined_communities = Community.query.filter_by(banned=False).join(CommunityMember).filter(CommunityMember.user_id == current_user.id)
# get the joined community ids list
joined_ids = []
for jc in joined_communities:
joined_ids.append(jc.id)
# filter out the joined communities from all communities
communities = all_communities.filter(Community.id.not_in(joined_ids))
if search_param == '':
pass
else:
communities = []
next_url = None
prev_url = None
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
return render_template('list_communities.html', communities=communities, search=search_param, title=_('Not Joined Communities'),
SUBSCRIPTION_PENDING=SUBSCRIPTION_PENDING, SUBSCRIPTION_MEMBER=SUBSCRIPTION_MEMBER,

View file

@ -709,6 +709,7 @@ class User(UserMixin, db.Model):
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])
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_profile_id = db.Column(db.String(255), index=True, unique=True) # e.g. https://server/u/username
@ -1021,6 +1022,7 @@ class User(UserMixin, db.Model):
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(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):
files = File.query.join(Post).filter(Post.user_id == self.id).all()
@ -1076,7 +1078,14 @@ class User(UserMixin, db.Model):
# returns true if the post has been read, false if not
def has_read_post(self, post):
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):
@ -1367,7 +1376,7 @@ class Post(db.Model):
i += 1
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',
community.low_quality) # the 512 sized image is for masonry view

View file

@ -39,7 +39,7 @@ document.addEventListener("DOMContentLoaded", function () {
function setupPostTeaserHandler() {
document.querySelectorAll('.post_teaser_clickable').forEach(div => {
div.onclick = function() {
const firstAnchor = this.parentElement.querySelector('a');
const firstAnchor = this.parentElement.querySelector('h3 a');
if (firstAnchor) {
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_topics') }}">{{ _('Topics') }}</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' %}
<a href="{{ url_for('admin.admin_approve_registrations') }}">{{ _('Registration applications') }}</a> |
{% endif %}
@ -15,6 +14,7 @@
<a href="{{ url_for('admin.newsletter') }}">{{ _('Newsletter') }}</a> |
<a href="{{ url_for('admin.admin_permissions') }}">{{ _('Permissions') }}</a> |
<a href="{{ url_for('admin.admin_activities') }}">{{ _('Activities') }}</a>
<a href="{{ url_for('main.modlog') }}">{{ _('Modlog') }}</a>
{% if debug_mode %}
| <a href="{{ url_for('dev.tools') }}">{{ _('Dev Tools') }}</a>
{% endif%}

View file

@ -11,44 +11,84 @@
<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="submit" name="submit" value="Search" class="btn btn-primary">
<form id="searchUsers" method="get">
<div>
<input type="search" name="search" placeholder="{{ _('Search') }}" value="{{ search }}">
<input type="submit" name="submit_search" 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>
<table class="table table-striped mt-1">
<tr>
<th title="{{ _('Display name.') }}">{{ _('Name') }}</th>
<th title="{{ _('Last seen.') }}">{{ _('Seen') }}</th>
<th title="{{ _('Attitude: Percentage of up votes vs. down votes the account made.') }}">{{ _('Attitude') }}</th>
<th title="{{ _('Reputation: The Karma of the account. Total up votes minus down votes they got.') }}">{{ _('Reputation') }}</th>
<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.') }}">
{{ _('Name') }}
<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 title="{{ _('How often a user has been reported.') }}">{{ _('Reports') }}</th>
<th title="{{ _('IP address of last interaction.') }}">{{ _('IP and country code') }}</th>
<th title="{{ _('Which website linked to PieFed when the user initially registered.') }}">{{ _('Source') }}</th>
<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.') }}">
{{ _('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>
</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 }}
{{ '<span class="red">Banned posts</span>'|safe if user.ban_posts }}
{{ '<span class="red">Banned comments</span>'|safe if user.ban_comments }}</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>{{ 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>{% if user.attitude != 1 %}{{ (user.attitude * 100) | round | int }}%{% endif %}</td>
<td>{% if user.reputation %}R {{ user.reputation | round | int }}{% endif %}</td>
<td><span title="{{ user.last_seen }}">{{ arrow.get(user.last_seen).humanize(locale=locale) }}</span></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>,
<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>
</tr>
{% endfor %}
@ -74,4 +114,4 @@
</div>
</div>
<hr />
{% endblock %}
{% endblock %}

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>
{{ render_form(form) }}
{% 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 %}
</div>
</div>

View file

@ -27,6 +27,12 @@
<span class="fe fe-warning orangered" title="Low reputation."> </span>
{% 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 -%}
</span>
{% 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_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_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_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>
@ -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_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_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_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 %}
<li><a class="dropdown-item{{ ' active' if active_child == 'dev_tools' }}" href="{{ url_for('dev.tools') }}">{{ _('Dev Tools') }}</a></li>
{% endif %}

View file

@ -27,27 +27,46 @@
</div>
</div>
<div class="col-auto">
{% if topics -%}
<form method="get" style="display:inline;">Topic:
<select name="topic_id" class="form-control-sm submit_on_change" aria-label="{{ _('Choose a topic to filter communities by') }}">
<option value="0">All</option>
{% for topic in topics -%}
<option value="{{ topic.id }}" {{ 'selected' if topic.id == topic_id }}>{{ topic.name }}</option>
{% endfor -%}
</select>
</form>
{% endif -%}
{% if languages -%}
<form method="get" 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>
</form>
{% endif -%}
<form method="get" style="display:inline;"><input type="search" name="search" placeholder="{{ _('Search') }}" value="{{ search }}"></form>
<form
id="searchCommunities"
method="get"
>
{% if topics -%}
<div style="display:inline;">
Topic:
<select name="topic_id"
class="form-control-sm submit_on_change"
aria-label="{{ _('Choose a topic to filter communities by') }}"
>
<option value="0">All</option>
{% for topic in topics -%}
<option value="{{ topic.id }}" {{ 'selected' if topic.id == topic_id }}>{{ topic.name }}
</option>
{% endfor -%}
</select>
</div>
{% endif -%}
{% if languages -%}
<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 class="col-auto">
<div class="btn-group">
@ -67,24 +86,56 @@
<tr>
<th> </th>
<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>
</a>
</button>
</th>
<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>
</a>
</button>
</th>
<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>
</a>
</button>
</th>
<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>
</a>
</button>
</th>
</tr>
</thead>

View file

@ -4,7 +4,7 @@
{% extends "base.html" -%}
{% endif -%} -%}
{% from 'bootstrap5/form.html' import render_form -%}
{% set active_child = 'list_communities' -%}
{% set active_child = 'modlog' -%}
{% block app_content -%}
<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 -%}
<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>
</div>
{% 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_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 />
{% 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 user.bot %}
{{ _('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 %}
{{ _('Posts') }}: {{ user.post_count }}<br />
{{ _('Comments') }}: {{ user.post_reply_count }}<br />
{% if current_user.is_authenticated %}{{ _('User note') }}: {{ user.get_note(current_user) }}<br />{% endif %}
</p>
<div class="profile_bio">
{{ 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'})
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.models import Post, Community, CommunityMember, User, PostReply, PostVote, Notification, utcnow, File, Site, \
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.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.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, \
@ -1330,3 +1330,36 @@ def user_read_posts_delete():
db.session.commit()
flash(_('Reading history has been deleted'))
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 mimetypes
import random
import tempfile
import urllib
from collections import defaultdict
from datetime import datetime, timedelta, date
@ -13,11 +12,9 @@ from typing import List, Literal, Union
import httpx
import markdown2
import math
from urllib.parse import urlparse, parse_qs, urlencode
from functools import wraps
import flask
import requests
from bs4 import BeautifulSoup, MarkupResemblesLocatorWarning
import warnings
import jwt
@ -35,7 +32,6 @@ from wtforms.fields import SelectField, SelectMultipleField
from wtforms.widgets import Select, html_params, ListWidget, CheckboxInput
from app import db, cache, httpx_client
import re
from moviepy.editor import VideoFileClip
from PIL import Image, ImageOps
from app.models import Settings, Domain, Instance, BannedInstances, User, Community, DomainBlock, ActivityPubLog, IpBan, \
@ -1117,49 +1113,6 @@ def in_sorted_list(arr, 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 = requests.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)
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'),

View file

@ -1,4 +1,5 @@
services:
db:
shm_size: 128mb
image: postgres
@ -6,37 +7,62 @@ services:
- ./.env.docker
volumes:
- ./pgdata:/var/lib/postgresql/data
networks:
- pf_network
redis:
image: redis
env_file:
- ./.env.docker
networks:
- pf_network
celery:
build:
context: .
target: builder
container_name: piefed_celery1
depends_on:
- db
- redis
env_file:
- ./.env.docker
entrypoint: ./entrypoint_celery.sh
volumes:
- ./media/:/app/app/static/media/
web:
networks:
- pf_network
web:
build:
context: .
target: builder
container_name: piefed_app1
depends_on:
- db
- redis
env_file:
- ./.env.docker
volumes:
- ./.gunicorn.conf.py:/app/gunicorn.conf.py
- ./gunicorn.conf.py:/app/gunicorn.conf.py
- ./media/:/app/app/static/media/
ports:
- '8080:5000'
- '8030:5000'
networks:
- pf_network
adminer:
image: adminer
restart: always
ports:
- 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,5 +32,4 @@ Werkzeug==2.3.3
pytesseract==0.3.10
sentry-sdk==1.40.6
python-slugify==8.0.4
moviepy==1.0.3
furl==2.1.3