upload images for community icon and banner

This commit is contained in:
rimu 2023-12-08 17:13:38 +13:00
parent d5d7122a3d
commit 9efda995e3
14 changed files with 283 additions and 33 deletions

View file

@ -18,7 +18,7 @@ from sqlalchemy_searchable import make_searchable
from config import Config from config import Config
db = SQLAlchemy(session_options={"autoflush": False}) db = SQLAlchemy(session_options={"autoflush": False}) # engine_options={'pool_size': 5, 'max_overflow': 10}
migrate = Migrate() migrate = Migrate()
login = LoginManager() login = LoginManager()
login.login_view = 'auth.login' login.login_view = 'auth.login'

View file

@ -14,7 +14,7 @@ from app.activitypub.util import public_key, users_total, active_half_year, acti
post_to_activity, find_actor_or_create, default_context, instance_blocked, find_reply_parent, find_liked_object, \ post_to_activity, find_actor_or_create, default_context, instance_blocked, find_reply_parent, find_liked_object, \
lemmy_site_data, instance_weight lemmy_site_data, instance_weight
from app.utils import gibberish, get_setting, is_image_url, allowlist_html, html_to_markdown, render_template, \ from app.utils import gibberish, get_setting, is_image_url, allowlist_html, html_to_markdown, render_template, \
domain_from_url, markdown_to_html, community_membership domain_from_url, markdown_to_html, community_membership, ap_datetime
import werkzeug.exceptions import werkzeug.exceptions
INBOX = [] INBOX = []
@ -159,12 +159,17 @@ def user_profile(actor):
"endpoints": { "endpoints": {
"sharedInbox": f"https://{server}/inbox" "sharedInbox": f"https://{server}/inbox"
}, },
"published": user.created.isoformat() + '+00:00', "published": ap_datetime(user.created),
} }
if user.avatar_id is not None: if user.avatar_id is not None:
actor_data["icon"] = { actor_data["icon"] = {
"type": "Image", "type": "Image",
"url": f"https://{server}/avatars/{user.avatar.file_path}" "url": f"https://{current_app.config['SERVER_NAME']}{user.avatar_image()}"
}
if user.cover_id is not None:
actor_data["image"] = {
"type": "Image",
"url": f"https://{current_app.config['SERVER_NAME']}{user.cover_image()}"
} }
if user.about: if user.about:
actor_data['source'] = { actor_data['source'] = {
@ -219,13 +224,18 @@ def community_profile(actor):
"endpoints": { "endpoints": {
"sharedInbox": f"https://{server}/inbox" "sharedInbox": f"https://{server}/inbox"
}, },
"published": community.created_at.isoformat() + '+00:00', "published": ap_datetime(community.created_at),
"updated": community.last_active.isoformat() + '+00:00', "updated": ap_datetime(community.last_active),
} }
if community.icon_id is not None: if community.icon_id is not None:
actor_data["icon"] = { actor_data["icon"] = {
"type": "Image", "type": "Image",
"url": f"https://{server}/avatars/{community.icon.file_path}" "url": f"https://{current_app.config['SERVER_NAME']}{community.icon_image()}"
}
if community.image_id is not None:
actor_data["image"] = {
"type": "Image",
"url": f"https://{current_app.config['SERVER_NAME']}{community.header_image()}"
} }
resp = jsonify(actor_data) resp = jsonify(actor_data)
resp.content_type = 'application/activity+json' resp.content_type = 'application/activity+json'

View file

@ -8,8 +8,10 @@ from app.utils import domain_from_url
class AddLocalCommunity(FlaskForm): class AddLocalCommunity(FlaskForm):
community_name = StringField(_l('Name'), validators=[DataRequired()]) community_name = StringField(_l('Name'), validators=[DataRequired()])
url = StringField(_l('Url'), render_kw={'placeholder': '/c/'}) url = StringField(_l('Url'))
description = TextAreaField(_l('Description')) description = TextAreaField(_l('Description'))
icon_file = FileField(_('Icon image'))
banner_file = FileField(_('Banner image'))
rules = TextAreaField(_l('Rules')) rules = TextAreaField(_l('Rules'))
nsfw = BooleanField('18+ NSFW') nsfw = BooleanField('18+ NSFW')
submit = SubmitField(_l('Create')) submit = SubmitField(_l('Create'))

View file

@ -9,14 +9,14 @@ from app.activitypub.signature import RsaKeys, HttpSignature
from app.activitypub.util import default_context from app.activitypub.util import default_context
from app.community.forms import SearchRemoteCommunity, AddLocalCommunity, CreatePostForm from app.community.forms import SearchRemoteCommunity, AddLocalCommunity, CreatePostForm
from app.community.util import search_for_community, community_url_exists, actor_to_community, \ from app.community.util import search_for_community, community_url_exists, actor_to_community, \
ensure_directory_exists, opengraph_parse, url_to_thumbnail_file, save_post ensure_directory_exists, opengraph_parse, url_to_thumbnail_file, save_post, save_icon_file, save_banner_file
from app.constants import SUBSCRIPTION_MEMBER, SUBSCRIPTION_OWNER, POST_TYPE_LINK, POST_TYPE_ARTICLE, POST_TYPE_IMAGE, \ from app.constants import SUBSCRIPTION_MEMBER, SUBSCRIPTION_OWNER, POST_TYPE_LINK, POST_TYPE_ARTICLE, POST_TYPE_IMAGE, \
SUBSCRIPTION_PENDING SUBSCRIPTION_PENDING
from app.models import User, Community, CommunityMember, CommunityJoinRequest, CommunityBan, Post, \ from app.models import User, Community, CommunityMember, CommunityJoinRequest, CommunityBan, Post, \
File, PostVote File, PostVote
from app.community import bp from app.community import bp
from app.utils import get_setting, render_template, allowlist_html, markdown_to_html, validation_required, \ from app.utils import get_setting, render_template, allowlist_html, markdown_to_html, validation_required, \
shorten_string, markdown_to_text, domain_from_url, validate_image, gibberish, community_membership shorten_string, markdown_to_text, domain_from_url, validate_image, gibberish, community_membership, ap_datetime
import os import os
from PIL import Image, ImageOps from PIL import Image, ImageOps
from datetime import datetime from datetime import datetime
@ -39,6 +39,16 @@ def add_local():
public_key=public_key, public_key=public_key,
ap_profile_id=current_app.config['SERVER_NAME'] + '/c/' + form.url.data, ap_profile_id=current_app.config['SERVER_NAME'] + '/c/' + form.url.data,
subscriptions_count=1) subscriptions_count=1)
icon_file = request.files['icon_file']
if icon_file and icon_file.filename != '':
file = save_icon_file(icon_file)
if file:
community.icon = file
banner_file = request.files['banner_file']
if banner_file and banner_file.filename != '':
file = save_banner_file(banner_file)
if file:
community.image = file
db.session.add(community) db.session.add(community)
db.session.commit() db.session.commit()
membership = CommunityMember(user_id=current_user.id, community_id=community.id, is_moderator=True, membership = CommunityMember(user_id=current_user.id, community_id=community.id, is_moderator=True,
@ -228,7 +238,7 @@ def add_post(actor):
form.nsfw.render_kw = {'disabled': True} form.nsfw.render_kw = {'disabled': True}
if get_setting('allow_nsfl', False) is False: if get_setting('allow_nsfl', False) is False:
form.nsfl.render_kw = {'disabled': True} form.nsfl.render_kw = {'disabled': True}
images_disabled = 'disabled' if not get_setting('allow_local_image_posts', True) else '' images_disabled = 'disabled' if not get_setting('allow_local_image_posts', True) else '' # bug: this will disable posting of images to *remote* hosts too
form.communities.choices = [(c.id, c.display_name()) for c in current_user.communities()] form.communities.choices = [(c.id, c.display_name()) for c in current_user.communities()]
@ -239,10 +249,58 @@ def add_post(actor):
community.last_active = datetime.utcnow() community.last_active = datetime.utcnow()
db.session.commit() db.session.commit()
if community.ap_id: # this is a remote community - send the post to the instance that hosts it
page = {
'type': 'Page',
'id': f"https://{current_app.config['SERVER_NAME']}/post/{post.id}",
'attributedTo': current_user.ap_profile_id,
'to': [
community.ap_profile_id,
'https://www.w3.org/ns/activitystreams#Public'
],
'name': post.title,
'cc': [],
'content': post.body_html,
'mediaType': 'text/html',
'source': {
'content': post.body,
'mediaType': 'text/markdown'
},
'attachment': [],
'commentsEnabled': post.comments_enabled,
'sensitive': post.nsfw,
'nsfl': post.nsfl,
'published': ap_datetime(datetime.utcnow()),
'audience': community.ap_profile_id
}
create = {
"id": f"https://{current_app.config['SERVER_NAME']}/activities/create/{gibberish(15)}",
"actor": current_user.ap_profile_id,
"to": [
"https://www.w3.org/ns/activitystreams#Public"
],
"cc": [
community.ap_profile_id
],
"type": "Create",
"audience": community.ap_profile_id,
"object": page
}
try:
message = HttpSignature.signed_request(community.ap_inbox_url, create, current_user.private_key,
current_user.ap_profile_id + '#main-key')
if message.status_code == 200:
flash('Your post has been sent to ' + community.title)
else:
flash('Response status code was not 200', 'warning')
current_app.logger.error('Response code for post attempt was ' +
str(message.status_code) + ' ' + message.text)
except Exception as ex:
flash('Failed to send request to subscribe: ' + str(ex), 'error')
current_app.logger.error("Exception while trying to subscribe" + str(ex))
else: # local community - send post out to followers
...
# todo: federate post creation out to followers
flash('Post has been added')
return redirect(f"/c/{community.link()}") return redirect(f"/c/{community.link()}")
else: else:
form.communities.data = community.id form.communities.data = community.id

View file

@ -16,6 +16,8 @@ import os
from opengraph_parse import parse_page from opengraph_parse import parse_page
allowed_extensions = ['.gif', '.jpg', '.jpeg', '.png', '.webp', '.heic']
def search_for_community(address: str): def search_for_community(address: str):
if address.startswith('!'): if address.startswith('!'):
name, server = address[1:].split('@') name, server = address[1:].split('@')
@ -176,7 +178,6 @@ def save_post(form, post):
db.session.add(file) db.session.add(file)
elif form.type.data == 'image': elif form.type.data == 'image':
allowed_extensions = ['.gif', '.jpg', '.jpeg', '.png', '.webp', '.heic']
post.title = form.image_title.data post.title = form.image_title.data
post.type = POST_TYPE_IMAGE post.type = POST_TYPE_IMAGE
uploaded_file = request.files['image_file'] uploaded_file = request.files['image_file']
@ -185,15 +186,18 @@ def save_post(form, post):
remove_old_file(post.image_id) remove_old_file(post.image_id)
post.image_id = None post.image_id = None
# check if this is an allowed type of file
file_ext = os.path.splitext(uploaded_file.filename)[1] file_ext = os.path.splitext(uploaded_file.filename)[1]
if file_ext.lower() not in allowed_extensions or file_ext != validate_image( if file_ext.lower() not in allowed_extensions or file_ext != validate_image(
uploaded_file.stream): uploaded_file.stream):
abort(400) abort(400)
new_filename = gibberish(15) new_filename = gibberish(15)
# set up the storage directory
directory = 'app/static/media/posts/' + new_filename[0:2] + '/' + new_filename[2:4] directory = 'app/static/media/posts/' + new_filename[0:2] + '/' + new_filename[2:4]
ensure_directory_exists(directory) ensure_directory_exists(directory)
# save the file
final_place = os.path.join(directory, new_filename + file_ext) final_place = os.path.join(directory, new_filename + file_ext)
final_place_thumbnail = os.path.join(directory, new_filename + '_thumbnail.webp') final_place_thumbnail = os.path.join(directory, new_filename + '_thumbnail.webp')
uploaded_file.save(final_place) uploaded_file.save(final_place)
@ -203,14 +207,15 @@ def save_post(form, post):
# resize if necessary # resize if necessary
img = Image.open(final_place) img = Image.open(final_place)
img = ImageOps.exif_transpose(img)
img_width = img.width img_width = img.width
img_height = img.height img_height = img.height
img = ImageOps.exif_transpose(img)
if img.width > 2000 or img.height > 2000: if img.width > 2000 or img.height > 2000:
img.thumbnail((2000, 2000)) img.thumbnail((2000, 2000))
img.save(final_place) img.save(final_place)
img_width = img.width img_width = img.width
img_height = img.height img_height = img.height
# save a second, smaller, version as a thumbnail
img.thumbnail((256, 256)) img.thumbnail((256, 256))
img.save(final_place_thumbnail, format="WebP", quality=93) img.save(final_place_thumbnail, format="WebP", quality=93)
thumbnail_width = img.width thumbnail_width = img.width
@ -237,3 +242,83 @@ def save_post(form, post):
def remove_old_file(file_id): def remove_old_file(file_id):
remove_file = File.query.get(file_id) remove_file = File.query.get(file_id)
remove_file.delete_from_disk() remove_file.delete_from_disk()
def save_icon_file(icon_file) -> File:
# check if this is an allowed type of file
file_ext = os.path.splitext(icon_file.filename)[1]
if file_ext.lower() not in allowed_extensions or file_ext != validate_image(
icon_file.stream):
abort(400)
new_filename = gibberish(15)
# set up the storage directory
directory = 'app/static/media/communities/' + new_filename[0:2] + '/' + new_filename[2:4]
ensure_directory_exists(directory)
# save the file
final_place = os.path.join(directory, new_filename + file_ext)
final_place_thumbnail = os.path.join(directory, new_filename + '_thumbnail.webp')
icon_file.save(final_place)
if file_ext.lower() == '.heic':
register_heif_opener()
# resize if necessary
img = Image.open(final_place)
img = ImageOps.exif_transpose(img)
img_width = img.width
img_height = img.height
if img.width > 200 or img.height > 200:
img.thumbnail((200, 200))
img.save(final_place)
img_width = img.width
img_height = img.height
# save a second, smaller, version as a thumbnail
img.thumbnail((32, 32))
img.save(final_place_thumbnail, format="WebP", quality=93)
thumbnail_width = img.width
thumbnail_height = img.height
file = File(file_path=final_place, file_name=new_filename + file_ext, alt_text='community icon',
width=img_width, height=img_height, thumbnail_width=thumbnail_width,
thumbnail_height=thumbnail_height, thumbnail_path=final_place_thumbnail)
db.session.add(file)
return file
def save_banner_file(banner_file) -> File:
# check if this is an allowed type of file
file_ext = os.path.splitext(banner_file.filename)[1]
if file_ext.lower() not in allowed_extensions or file_ext != validate_image(
banner_file.stream):
abort(400)
new_filename = gibberish(15)
# set up the storage directory
directory = 'app/static/media/communities/' + new_filename[0:2] + '/' + new_filename[2:4]
ensure_directory_exists(directory)
# save the file
final_place = os.path.join(directory, new_filename + file_ext)
final_place_thumbnail = os.path.join(directory, new_filename + '_thumbnail.webp')
banner_file.save(final_place)
if file_ext.lower() == '.heic':
register_heif_opener()
# resize if necessary
img = Image.open(final_place)
img = ImageOps.exif_transpose(img)
img_width = img.width
img_height = img.height
if img.width > 1000 or img.height > 300:
img.thumbnail((1000, 300))
img.save(final_place)
img_width = img.width
img_height = img.height
file = File(file_path=final_place, file_name=new_filename + file_ext, alt_text='community banner',
width=img_width, height=img_height)
db.session.add(file)
return file

View file

@ -103,21 +103,45 @@ class Community(db.Model):
image = db.relationship('File', foreign_keys=[image_id], single_parent=True, cascade="all, delete-orphan") image = db.relationship('File', foreign_keys=[image_id], single_parent=True, cascade="all, delete-orphan")
@cache.memoize(timeout=500) @cache.memoize(timeout=500)
def icon_image(self) -> str: def icon_image(self, size='default') -> str:
if self.icon_id is not None: if self.icon_id is not None:
if self.icon.file_path is not None: if size == 'default':
return self.icon.file_path if self.icon.file_path is not None:
if self.icon.source_url is not None: if self.icon.file_path.startswith('app/'):
return self.icon.source_url return self.icon.file_path.replace('app/', '/')
else:
return self.icon.file_path
if self.icon.source_url is not None:
if self.icon.source_url.startswith('app/'):
return self.icon.source_url.replace('app/', '/')
else:
return self.icon.source_url
elif size == 'tiny':
if self.icon.thumbnail_path is not None:
if self.icon.thumbnail_path.startswith('app/'):
return self.icon.thumbnail_path.replace('app/', '/')
else:
return self.icon.thumbnail_path
if self.icon.source_url is not None:
if self.icon.source_url.startswith('app/'):
return self.icon.source_url.replace('app/', '/')
else:
return self.icon.source_url
return '' return ''
@cache.memoize(timeout=500) @cache.memoize(timeout=500)
def header_image(self) -> str: def header_image(self) -> str:
if self.image_id is not None: if self.image_id is not None:
if self.image.file_path is not None: if self.image.file_path is not None:
return self.image.file_path if self.image.file_path.startswith('app/'):
return self.image.file_path.replace('app/', '/')
else:
return self.image.file_path
if self.image.source_url is not None: if self.image.source_url is not None:
return self.image.source_url if self.image.source_url.startswith('app/'):
return self.image.source_url.replace('app/', '/')
else:
return self.image.source_url
return '' return ''
def display_name(self) -> str: def display_name(self) -> str:
@ -143,6 +167,9 @@ class Community(db.Model):
def is_moderator(self): def is_moderator(self):
return any(moderator.user_id == current_user.id for moderator in self.moderators()) return any(moderator.user_id == current_user.id for moderator in self.moderators())
def profile_id(self):
return self.ap_profile_id if self.ap_profile_id else f"https://{current_app.config['SERVER_NAME']}/c/{self.name}"
user_role = db.Table('user_role', user_role = db.Table('user_role',
db.Column('user_id', db.Integer, db.ForeignKey('user.id')), db.Column('user_id', db.Integer, db.ForeignKey('user.id')),
@ -232,18 +259,30 @@ class User(UserMixin, db.Model):
def avatar_image(self) -> str: def avatar_image(self) -> str:
if self.avatar_id is not None: if self.avatar_id is not None:
if self.avatar.file_path is not None: if self.avatar.file_path is not None:
return self.avatar.file_path if self.avatar.file_path.startswith('app/'):
return self.avatar.file_path.replace('app/', '/')
else:
return self.avatar.file_path
if self.avatar.source_url is not None: if self.avatar.source_url is not None:
return self.avatar.source_url if self.avatar.source_url.startswith('app/'):
return self.avatar.source_url.replace('app/', '/')
else:
return self.avatar.source_url
return '' return ''
@cache.memoize(timeout=500) @cache.memoize(timeout=500)
def cover_image(self) -> str: def cover_image(self) -> str:
if self.cover_id is not None: if self.cover_id is not None:
if self.cover.file_path is not None: if self.cover.file_path is not None:
return self.cover.file_path if self.cover.file_path.startswith('app/'):
return self.cover.file_path.replace('app/', '/')
else:
return self.cover.file_path
if self.cover.source_url is not None: if self.cover.source_url is not None:
return self.cover.source_url if self.cover.source_url.startswith('app/'):
return self.cover.source_url.replace('app/', '/')
else:
return self.cover.source_url
return '' return ''
def link(self) -> str: def link(self) -> str:
@ -302,7 +341,7 @@ class User(UserMixin, db.Model):
join(CommunityMember).filter(CommunityMember.is_banned == False).all() join(CommunityMember).filter(CommunityMember.is_banned == False).all()
def profile_id(self): def profile_id(self):
return self.ap_profile_id if self.ap_profile_id else f"{self.user_name}@{current_app.config['SERVER_NAME']}" return self.ap_profile_id if self.ap_profile_id else f"https://{current_app.config['SERVER_NAME']}/u/{self.user_name}"
def created_recently(self): def created_recently(self):
return self.created and self.created > datetime.utcnow() - timedelta(days=7) return self.created and self.created > datetime.utcnow() - timedelta(days=7)
@ -413,6 +452,9 @@ class Post(db.Model):
if vpos != -1: if vpos != -1:
return self.url[vpos + 2:vpos + 13] return self.url[vpos + 2:vpos + 13]
def profile_id(self):
return f"https://{current_app.config['SERVER_NAME']}/post/{self.id}"
class PostReply(db.Model): class PostReply(db.Model):
query_class = FullTextSearchQuery query_class = FullTextSearchQuery
@ -452,6 +494,9 @@ class PostReply(db.Model):
def get_by_ap_id(cls, ap_id): def get_by_ap_id(cls, ap_id):
return cls.query.filter_by(ap_id=ap_id).first() return cls.query.filter_by(ap_id=ap_id).first()
def profile_id(self):
return f"https://{current_app.config['SERVER_NAME']}/comment/{self.id}"
class Domain(db.Model): class Domain(db.Model):
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)

View file

@ -569,6 +569,12 @@ fieldset legend {
border-top: solid 1px #ddd; border-top: solid 1px #ddd;
} }
#add_local_community_form #url {
width: 297px;
display: inline-block;
padding-left: 3px;
}
.table tr th { .table tr th {
vertical-align: middle; vertical-align: middle;
} }

View file

@ -317,6 +317,14 @@ nav, etc which are used site-wide */
} }
} }
#add_local_community_form {
#url {
width: 297px;
display: inline-block;
padding-left: 3px;
}
}
.table { .table {
tr th { tr th {
vertical-align: middle; vertical-align: middle;

View file

@ -470,6 +470,12 @@ nav.navbar {
margin-bottom: 15px; margin-bottom: 15px;
} }
.field_hint {
margin-top: -15px;
display: block;
margin-bottom: 10px;
}
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
body { body {
background-color: #777; background-color: #777;

View file

@ -191,6 +191,12 @@ nav.navbar {
} }
} }
.field_hint {
margin-top: -15px;
display: block;
margin-bottom: 10px;
}
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
body { body {
background-color: $dark-grey; background-color: $dark-grey;

View file

@ -1,5 +1,5 @@
{% extends "base.html" %} {% extends "base.html" %}
{% from 'bootstrap/form.html' import render_form %} {% from 'bootstrap/form.html' import render_field %}
{% block app_content %} {% block app_content %}
<div class="row"> <div class="row">
@ -7,7 +7,24 @@
<div class="card mt-5"> <div class="card mt-5">
<div class="card-body p-6"> <div class="card-body p-6">
<div class="card-title">{{ _('Create community') }}</div> <div class="card-title">{{ _('Create community') }}</div>
{{ render_form(form) }} <form method="post" enctype="multipart/form-data" id="add_local_community_form">
{{ form.csrf_token() }}
{{ render_field(form.community_name) }}
<div class="form-group">{{ form.url.label(class_="form-control-label required") }}
/c/{{ form.url(class_="form-control", maxlength=255) }}
{% for error in form.url.errors %}
<span style="color: red;">[{{ error }}]</span>
{% endfor %}
</div>
{{ render_field(form.description) }}
{{ render_field(form.icon_file) }}
<small class="field_hint">Provide a square image that looks good when small.</small>
{{ render_field(form.banner_file) }}
<small class="field_hint">Provide a wide image - letterbox orientation.</small>
{{ render_field(form.rules) }}
{{ render_field(form.nsfw) }}
{{ render_field(form.submit) }}
</form>
</div> </div>
</div> </div>
</div> </div>

View file

@ -1,5 +1,5 @@
{% extends "base.html" %} {% extends "base.html" %}
{% from 'bootstrap/form.html' import render_form, render_field %} {% from 'bootstrap/form.html' import render_field %}
{% block app_content %} {% block app_content %}
<div class="row"> <div class="row">

View file

@ -44,7 +44,7 @@
<tbody> <tbody>
{% for community in communities %} {% for community in communities %}
<tr class=""> <tr class="">
<td width="46"><a href="/c/{{ community.link() }}"><img src="{{ community.icon_image() }}" class="community_icon rounded-circle" loading="lazy" /></a></td> <td width="46"><a href="/c/{{ community.link() }}"><img src="{{ community.icon_image('tiny') }}" class="community_icon rounded-circle" loading="lazy" /></a></td>
<td scope="row" class="pl-0"><a href="/c/{{ community.link() }}">{{ community.display_name() }}</a></td> <td scope="row" class="pl-0"><a href="/c/{{ community.link() }}">{{ community.display_name() }}</a></td>
<td>{{ community.post_count }}</td> <td>{{ community.post_count }}</td>
<td>{{ community.post_reply_count }}</td> <td>{{ community.post_reply_count }}</td>

View file

@ -1,4 +1,6 @@
import random import random
from datetime import datetime
import markdown2 import markdown2
import math import math
from urllib.parse import urlparse from urllib.parse import urlparse
@ -198,7 +200,7 @@ def user_access(permission: str, user_id: int) -> bool:
return has_access is not None return has_access is not None
@cache.memoize(timeout=10) @cache.memoize(timeout=500)
def community_membership(user: User, community: Community) -> int: def community_membership(user: User, community: Community) -> int:
# @cache.memoize works with User.subscribed but cache.delete_memoized does not, making it bad to use on class methods. # @cache.memoize works with User.subscribed but cache.delete_memoized does not, making it bad to use on class methods.
# however cache.memoize and cache.delete_memoized works fine with normal functions # however cache.memoize and cache.delete_memoized works fine with normal functions
@ -261,3 +263,8 @@ def back(default_url):
# If referrer is not available or is the same as the current request URL, redirect to the default URL # If referrer is not available or is the same as the current request URL, redirect to the default URL
return redirect(default_url) return redirect(default_url)
# format a datetime in a way that is used in ActivityPub
def ap_datetime(date_time: datetime) -> str:
return date_time.isoformat() + '+00:00'