mirror of
https://codeberg.org/rimu/pyfedi
synced 2025-01-23 19:36:56 -08:00
Merge branch 'main' into feature/image_post_editing
This commit is contained in:
commit
dae5441604
24 changed files with 614 additions and 474 deletions
|
@ -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
|
||||
|
|
111
INSTALL.md
111
INSTALL.md
|
@ -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
|
||||
|
|
|
@ -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]:
|
||||
|
|
|
@ -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(),
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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%}
|
||||
|
|
|
@ -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 %}
|
||||
|
|
|
@ -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">←</span> {{ _('Previous page') }}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if next_url %}
|
||||
<a href="{{ next_url }}" class="btn btn-primary">
|
||||
{{ _('Next page') }} <span aria-hidden="true">→</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
<hr />
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
{% include 'admin/_nav.html' %}
|
||||
</div>
|
||||
</div>
|
||||
<hr />
|
||||
{% endblock %}
|
|
@ -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>
|
||||
|
|
|
@ -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 %}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
68
app/templates/user/edit_note.html
Normal file
68
app/templates/user/edit_note.html
Normal 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 %}
|
|
@ -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 }}
|
||||
|
|
|
@ -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'))
|
||||
|
|
|
@ -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)
|
||||
|
|
47
app/utils.py
47
app/utils.py
|
@ -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'),
|
||||
|
|
32
compose.yaml
32
compose.yaml
|
@ -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
0
entrypoint.sh
Normal file → Executable file
0
entrypoint_celery.sh
Normal file → Executable file
0
entrypoint_celery.sh
Normal file → Executable file
24
env.docker.sample
Normal file
24
env.docker.sample
Normal 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
|
||||
|
||||
|
||||
|
|
@ -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
|
||||
|
|
Loading…
Reference in a new issue