This commit is contained in:
rimu 2024-02-19 15:01:53 +13:00
parent 15a18550d9
commit e840db1991
21 changed files with 518 additions and 252 deletions

View file

@ -83,7 +83,7 @@ def nodeinfo():
@bp.route('/.well-known/host-meta') @bp.route('/.well-known/host-meta')
@cache.cached(timeout=600) @cache.cached(timeout=600)
def host_meta(): def host_meta():
resp = make_response(f'<?xml version="1.0" encoding="UTF-8"?>\n<XRD xmlns="http://docs.oasis-open.org/ns/xri/xrd-1.0">\n<Link rel="lrdd" template="https://{current_app.config["SERVER_NAME"]}/.well-known/webfinger?resource={uri}"/>\n</XRD>') resp = make_response('<?xml version="1.0" encoding="UTF-8"?>\n<XRD xmlns="http://docs.oasis-open.org/ns/xri/xrd-1.0">\n<Link rel="lrdd" template="https://' + current_app.config["SERVER_NAME"] + '/.well-known/webfinger?resource={uri}"/>\n</XRD>')
resp.content_type = 'application/xrd+xml; charset=utf-8' resp.content_type = 'application/xrd+xml; charset=utf-8'
return resp return resp
@ -380,7 +380,9 @@ def process_inbox_request(request_json, activitypublog_id, ip_address):
recipient_ap_id = request_json['object']['to'][0] recipient_ap_id = request_json['object']['to'][0]
recipient = find_actor_or_create(recipient_ap_id) recipient = find_actor_or_create(recipient_ap_id)
if sender and recipient and recipient.is_local(): if sender and recipient and recipient.is_local():
if recipient.has_blocked_user(sender.id) or recipient.has_blocked_instance(sender.instance_id): if sender.created_recently() or sender.reputation < 10:
activity_log.exception_message = "Sender not eligible to send"
elif recipient.has_blocked_user(sender.id) or recipient.has_blocked_instance(sender.instance_id):
activity_log.exception_message = "Sender blocked by recipient" activity_log.exception_message = "Sender blocked by recipient"
else: else:
# Save ChatMessage to DB # Save ChatMessage to DB

View file

@ -98,10 +98,13 @@ class EditTopicForm(FlaskForm):
class EditUserForm(FlaskForm): class EditUserForm(FlaskForm):
about = TextAreaField(_l('Bio'), validators=[Optional(), Length(min=3, max=5000)]) about = TextAreaField(_l('Bio'), validators=[Optional(), Length(min=3, max=5000)])
email = StringField(_l('Email address'), validators=[Optional(), Length(max=255)])
matrix_user_id = StringField(_l('Matrix User ID'), validators=[Optional(), Length(max=255)]) matrix_user_id = StringField(_l('Matrix User ID'), validators=[Optional(), Length(max=255)])
profile_file = FileField(_l('Avatar image')) profile_file = FileField(_l('Avatar image'))
banner_file = FileField(_l('Top banner image')) banner_file = FileField(_l('Top banner image'))
bot = BooleanField(_l('This profile is a bot')) bot = BooleanField(_l('This profile is a bot'))
verified = BooleanField(_l('Email address is verified'))
banned = BooleanField(_l('Banned'))
newsletter = BooleanField(_l('Subscribe to email newsletter')) newsletter = BooleanField(_l('Subscribe to email newsletter'))
ignore_bots = BooleanField(_l('Hide posts by bots')) ignore_bots = BooleanField(_l('Hide posts by bots'))
nsfw = BooleanField(_l('Show NSFW posts')) nsfw = BooleanField(_l('Show NSFW posts'))

View file

@ -484,9 +484,12 @@ def admin_user_edit(user_id):
user = User.query.get_or_404(user_id) user = User.query.get_or_404(user_id)
if form.validate_on_submit(): if form.validate_on_submit():
user.about = form.about.data user.about = form.about.data
user.email = form.email.data
user.about_html = markdown_to_html(form.about.data) user.about_html = markdown_to_html(form.about.data)
user.matrix_user_id = form.matrix_user_id.data user.matrix_user_id = form.matrix_user_id.data
user.bot = form.bot.data user.bot = form.bot.data
user.verified = form.verified.data
user.banned = form.banned.data
profile_file = request.files['profile_file'] profile_file = request.files['profile_file']
if profile_file and profile_file.filename != '': if profile_file and profile_file.filename != '':
# remove old avatar # remove old avatar
@ -528,9 +531,12 @@ def admin_user_edit(user_id):
if not user.is_local(): if not user.is_local():
flash(_('This is a remote user - most settings here will be regularly overwritten with data from the original server.'), 'warning') flash(_('This is a remote user - most settings here will be regularly overwritten with data from the original server.'), 'warning')
form.about.data = user.about form.about.data = user.about
form.email.data = user.email
form.matrix_user_id.data = user.matrix_user_id form.matrix_user_id.data = user.matrix_user_id
form.newsletter.data = user.newsletter form.newsletter.data = user.newsletter
form.bot.data = user.bot form.bot.data = user.bot
form.verified.data = user.verified
form.banned.data = user.banned
form.ignore_bots.data = user.ignore_bots form.ignore_bots.data = user.ignore_bots
form.nsfw.data = user.show_nsfw form.nsfw.data = user.show_nsfw
form.nsfl.data = user.show_nsfl form.nsfl.data = user.show_nsfl

View file

@ -38,7 +38,7 @@ def login():
return redirect(url_for('auth.login')) return redirect(url_for('auth.login'))
flash(_('Invalid password')) flash(_('Invalid password'))
return redirect(url_for('auth.login')) return redirect(url_for('auth.login'))
if user.banned or user_ip_banned() or user_cookie_banned(): if user.id != 1 and (user.banned or user_ip_banned() or user_cookie_banned()):
flash(_('You have been banned.'), 'error') flash(_('You have been banned.'), 'error')
response = make_response(redirect(url_for('auth.login'))) response = make_response(redirect(url_for('auth.login')))

View file

@ -6,9 +6,36 @@ from wtforms.validators import ValidationError, DataRequired, Email, EqualTo, Le
from flask_babel import _, lazy_gettext as _l from flask_babel import _, lazy_gettext as _l
from app import db from app import db
from app.utils import MultiCheckboxField
class AddReply(FlaskForm): class AddReply(FlaskForm):
message = TextAreaField(_l('Message'), validators=[DataRequired(), Length(min=1, max=5000)], render_kw={'placeholder': 'Type a reply here...'}) message = TextAreaField(_l('Message'), validators=[DataRequired(), Length(min=1, max=5000)], render_kw={'placeholder': 'Type a reply here...'})
recipient_id = HiddenField()
submit = SubmitField(_l('Reply')) submit = SubmitField(_l('Reply'))
class ReportConversationForm(FlaskForm):
reason_choices = [('7', _l('Spam')),
('2', _l('Harassment')),
('3', _l('Threatening violence')),
('4', _l('Promoting hate / genocide')),
('15', _l('Misinformation / disinformation')),
('16', _l('Racism, sexism, transphobia')),
('5', _l('Minor abuse or sexualization')),
('8', _l('Non-consensual intimate media')),
('9', _l('Prohibited transaction')), ('10', _l('Impersonation')),
('11', _l('Copyright violation')), ('12', _l('Trademark violation')),
('13', _l('Self-harm or suicide')),
('14', _l('Other'))]
reasons = MultiCheckboxField(_l('Reason'), choices=reason_choices)
description = StringField(_l('More info'))
report_remote = BooleanField('Also send report to originating instance')
submit = SubmitField(_l('Report'))
def reasons_to_string(self, reason_data) -> str:
result = []
for reason_id in reason_data:
for choice in self.reason_choices:
if choice[0] == reason_id:
result.append(str(choice[1]))
return ', '.join(result)

View file

@ -1,84 +1,37 @@
from datetime import datetime, timedelta from flask import request, flash, json, url_for, current_app, redirect, g, abort
from flask import request, flash, json, url_for, current_app, redirect, g
from flask_login import login_required, current_user from flask_login import login_required, current_user
from flask_babel import _ from flask_babel import _
from sqlalchemy import desc, or_, and_, text from sqlalchemy import desc, or_, and_, text
from app import db, celery from app import db, celery
from app.activitypub.signature import post_request from app.chat.forms import AddReply, ReportConversationForm
from app.chat.forms import AddReply from app.chat.util import send_message, find_existing_conversation
from app.models import AllowedInstances, BannedInstances, ActivityPubLog, utcnow, Site, Community, CommunityMember, \ from app.models import Site, User, Report, ChatMessage, Notification, InstanceBlock, Conversation, conversation_member
User, Instance, File, Report, Topic, UserRegistration, ChatMessage, Notification, InstanceBlock
from app.user.forms import ReportUserForm from app.user.forms import ReportUserForm
from app.utils import render_template, permission_required, set_setting, get_setting, gibberish, markdown_to_html, \ from app.utils import render_template, moderating_communities, joined_communities
moderating_communities, joined_communities, finalize_user_setup, theme_list, allowlist_html, shorten_string
from app.chat import bp from app.chat import bp
@bp.route('/chat', methods=['GET', 'POST']) @bp.route('/chat', methods=['GET', 'POST'])
@bp.route('/chat/<int:sender_id>', methods=['GET', 'POST']) @bp.route('/chat/<int:conversation_id>', methods=['GET', 'POST'])
@login_required @login_required
def chat_home(sender_id=None): def chat_home(conversation_id=None):
form = AddReply() form = AddReply()
if form.validate_on_submit(): if form.validate_on_submit():
recipient = User.query.get(form.recipient_id.data) reply = send_message(form, conversation_id)
reply = ChatMessage(sender_id=current_user.id, recipient_id=recipient.id, return redirect(url_for('chat.chat_home', conversation_id=conversation_id, _anchor=f'message_{reply.id}'))
body=form.message.data, body_html=allowlist_html(markdown_to_html(form.message.data)))
if recipient.is_local():
# Notify local recipient
notify = Notification(title=shorten_string('New message from ' + current_user.display_name()), url='/chat/' + str(current_user.id),
user_id=recipient.id,
author_id=current_user.id)
db.session.add(notify)
recipient.unread_notifications += 1
db.session.add(reply)
db.session.commit()
else: else:
db.session.add(reply) conversations = Conversation.query.join(conversation_member,
db.session.commit() conversation_member.c.conversation_id == Conversation.id). \
# Federate reply filter(conversation_member.c.user_id == current_user.id).order_by(desc(Conversation.updated_at)).limit(50).all()
reply_json = { if conversation_id is None:
"actor": current_user.profile_id(), return redirect(url_for('chat.chat_home', conversation_id=conversations[0].id))
"id": f"https://{current_app.config['SERVER_NAME']}/activities/create/{gibberish(15)}",
"object": {
"attributedTo": current_user.profile_id(),
"content": reply.body_html,
"id": f"https://{current_app.config['SERVER_NAME']}/private_message/{reply.id}",
"mediaType": "text/html",
"published": utcnow().isoformat() + 'Z', # Lemmy is inconsistent with the date format they use
"source": {
"content": reply.body,
"mediaType": "text/markdown"
},
"to": [
recipient.profile_id()
],
"type": "ChatMessage"
},
"to": [
recipient.profile_id()
],
"type": "Create"
}
success = post_request(recipient.ap_inbox_url, reply_json, current_user.private_key,
current_user.profile_id() + '#main-key')
if not success:
flash(_('Message failed to send to remote server. Try again later.'), 'error')
return redirect(url_for('chat.chat_home', sender_id=recipient.id, _anchor=f'message_{reply.id}'))
else: else:
senders = User.query.filter(User.banned == False).join(ChatMessage, ChatMessage.sender_id == User.id) conversation = Conversation.query.get_or_404(conversation_id)
senders = senders.filter(ChatMessage.recipient_id == current_user.id).order_by(desc(ChatMessage.created_at)).limit(500).all() if not current_user.is_admin() and not conversation.is_member(current_user):
abort(400)
if senders: if conversations:
messages_with = senders[0].id if sender_id is None else sender_id messages = conversation.messages.order_by(ChatMessage.created_at).all()
sender_id = messages_with
messages = ChatMessage.query.filter(or_(
and_(ChatMessage.recipient_id == current_user.id, ChatMessage.sender_id == messages_with),
and_(ChatMessage.recipient_id == messages_with, ChatMessage.sender_id == current_user.id))
)
messages = messages.order_by(ChatMessage.created_at).all()
if messages: if messages:
if messages[0].sender_id == current_user.id: if messages[0].sender_id == current_user.id:
other_party = User.query.get(messages[0].recipient_id) other_party = User.query.get(messages[0].recipient_id)
@ -86,19 +39,19 @@ def chat_home(sender_id=None):
other_party = User.query.get(messages[0].sender_id) other_party = User.query.get(messages[0].sender_id)
else: else:
other_party = None other_party = None
form.recipient_id.data = messages_with
else: else:
messages = [] messages = []
other_party = None other_party = None
if sender_id and int(sender_id):
sql = f"UPDATE notification SET read = true WHERE url = '/chat/{sender_id}' AND user_id = {current_user.id}" sql = f"UPDATE notification SET read = true WHERE url = '/chat/{conversation_id}' AND user_id = {current_user.id}"
db.session.execute(text(sql)) db.session.execute(text(sql))
db.session.commit() db.session.commit()
current_user.unread_notifications = Notification.query.filter_by(user_id=current_user.id, read=False).count() current_user.unread_notifications = Notification.query.filter_by(user_id=current_user.id, read=False).count()
db.session.commit() db.session.commit()
return render_template('chat/home.html', title=_('Chat with %(name)s', name=other_party.display_name()) if other_party else _('Chat'), return render_template('chat/conversation.html', title=_('Chat with %(name)s', name=other_party.display_name()) if other_party else _('Chat'),
senders=senders, messages=messages, other_party=other_party, form=form, conversations=conversations, messages=messages, form=form,
current_conversation=conversation_id, conversation=conversation,
moderating_communities=moderating_communities(current_user.get_id()), moderating_communities=moderating_communities(current_user.get_id()),
joined_communities=joined_communities(current_user.get_id()), joined_communities=joined_communities(current_user.get_id()),
site=g.site) site=g.site)
@ -108,63 +61,88 @@ def chat_home(sender_id=None):
@login_required @login_required
def new_message(to): def new_message(to):
recipient = User.query.get_or_404(to) recipient = User.query.get_or_404(to)
existing_conversation = ChatMessage.query.filter(or_( if current_user.created_recently() or current_user.reputation < 10 or current_user.banned or not current_user.verified:
and_(ChatMessage.recipient_id == current_user.id, ChatMessage.sender_id == recipient.id), return redirect(url_for('chat.denied'))
and_(ChatMessage.recipient_id == recipient.id, ChatMessage.sender_id == current_user.id)) if recipient.has_blocked_user(current_user.id) or current_user.has_blocked_user(recipient.id):
).first() return redirect(url_for('chat.blocked'))
existing_conversation = find_existing_conversation(recipient=recipient, sender=current_user)
if existing_conversation: if existing_conversation:
return redirect(url_for('chat.home', sender_id=recipient.id, _anchor='submit')) return redirect(url_for('chat.chat_home', conversation_id=existing_conversation.id, _anchor='message'))
form = AddReply() form = AddReply()
form.submit.label.text = _('Send')
if form.validate_on_submit(): if form.validate_on_submit():
flash(_('Message sent')) conversation = Conversation(user_id=current_user.id)
return redirect(url_for('chat.home', sender_id=recipient.id)) conversation.members.append(recipient)
conversation.members.append(current_user)
db.session.add(conversation)
db.session.commit()
reply = send_message(form, conversation.id)
return redirect(url_for('chat.chat_home', conversation_id=conversation.id, _anchor=f'message_{reply.id}'))
else:
return render_template('chat/new_message.html', form=form, title=_('New message to "%(recipient_name)s"', recipient_name=recipient.link()),
recipient=recipient,
moderating_communities=moderating_communities(current_user.get_id()),
joined_communities=joined_communities(current_user.get_id()),
site=g.site)
@bp.route('/chat/<int:sender_id>/options', methods=['GET', 'POST']) @bp.route('/chat/denied', methods=['GET'])
@login_required @login_required
def chat_options(sender_id): def denied():
sender = User.query.get_or_404(sender_id) return render_template('chat/denied.html')
return render_template('chat/chat_options.html', sender=sender,
@bp.route('/chat/blocked', methods=['GET'])
@login_required
def blocked():
return render_template('chat/blocked.html')
@bp.route('/chat/<int:conversation_id>/options', methods=['GET', 'POST'])
@login_required
def chat_options(conversation_id):
conversation = Conversation.query.get_or_404(conversation_id)
if current_user.is_admin() or current_user.is_member(current_user):
return render_template('chat/chat_options.html', conversation=conversation,
moderating_communities=moderating_communities(current_user.get_id()), moderating_communities=moderating_communities(current_user.get_id()),
joined_communities=joined_communities(current_user.get_id()), joined_communities=joined_communities(current_user.get_id()),
site=g.site site=g.site
) )
@bp.route('/chat/<int:sender_id>/delete', methods=['GET', 'POST']) @bp.route('/chat/<int:conversation_id>/delete', methods=['GET', 'POST'])
@login_required @login_required
def chat_delete(sender_id): def chat_delete(conversation_id):
sender = User.query.get_or_404(sender_id) conversation = Conversation.query.get_or_404(conversation_id)
ChatMessage.query.filter(or_( if current_user.is_admin() or current_user.is_member(current_user):
and_(ChatMessage.recipient_id == current_user.id, ChatMessage.sender_id == sender.id), Report.query.filter(Report.suspect_conversation_id == conversation.id).delete()
and_(ChatMessage.recipient_id == sender.id, ChatMessage.sender_id == current_user.id)) db.session.delete(conversation)
).delete()
db.session.commit() db.session.commit()
flash(_('Conversation deleted')) flash(_('Conversation deleted'))
return redirect(url_for('chat.chat_home')) return redirect(url_for('chat.chat_home'))
@bp.route('/chat/<int:sender_id>/block_instance', methods=['GET', 'POST']) @bp.route('/chat/<int:instance_id>/block_instance', methods=['GET', 'POST'])
@login_required @login_required
def block_instance(sender_id): def block_instance(instance_id):
sender = User.query.get_or_404(sender_id) existing = InstanceBlock.query.filter_by(user_id=current_user.id, instance_id=instance_id).first()
existing = InstanceBlock.query.filter_by(user_id=current_user.id, instance_id=sender.instance_id).first()
if not existing: if not existing:
db.session.add(InstanceBlock(user_id=current_user.id, instance_id=sender.instance_id)) db.session.add(InstanceBlock(user_id=current_user.id, instance_id=instance_id))
db.session.commit() db.session.commit()
flash(_('Instance blocked.')) flash(_('Instance blocked.'))
return redirect(url_for('chat.chat_home')) return redirect(url_for('chat.chat_home'))
@bp.route('/chat/<int:sender_id>/report', methods=['GET', 'POST']) @bp.route('/chat/<int:conversation_id>/report', methods=['GET', 'POST'])
@login_required @login_required
def chat_report(sender_id): def chat_report(conversation_id):
sender = User.query.get_or_404(sender_id) conversation = Conversation.query.get_or_404(conversation_id)
form = ReportUserForm() if current_user.is_admin() or current_user.is_member(current_user):
if not sender.banned: form = ReportConversationForm()
if form.validate_on_submit(): if form.validate_on_submit():
report = Report(reasons=form.reasons_to_string(form.reasons.data), description=form.description.data, report = Report(reasons=form.reasons_to_string(form.reasons.data), description=form.description.data,
type=0, reporter_id=current_user.id, suspect_user_id=sender.id) type=4, reporter_id=current_user.id, suspect_conversation_id=conversation_id)
db.session.add(report) db.session.add(report)
# Notify site admin # Notify site admin
@ -175,20 +153,18 @@ def chat_report(sender_id):
author_id=current_user.id) author_id=current_user.id)
db.session.add(notify) db.session.add(notify)
admin.unread_notifications += 1 admin.unread_notifications += 1
sender.reports += 1
db.session.commit() db.session.commit()
# todo: federate report to originating instance # todo: federate report to originating instance
if not sender.is_local() and form.report_remote.data: if form.report_remote.data:
... ...
flash(_('%(user_name)s has been reported, thank you!', user_name=sender.link())) flash(_('This conversation has been reported, thank you!'))
goto = request.args.get('redirect') if 'redirect' in request.args else f'/u/{sender.link()}' return redirect(url_for('chat.chat_home', conversation_id=conversation_id))
return redirect(goto)
elif request.method == 'GET': elif request.method == 'GET':
form.report_remote.data = True form.report_remote.data = True
return render_template('user/user_report.html', title=_('Report user'), form=form, user=sender, return render_template('chat/report.html', title=_('Report conversation'), form=form, conversation=conversation,
moderating_communities=moderating_communities(current_user.get_id()), moderating_communities=moderating_communities(current_user.get_id()),
joined_communities=joined_communities(current_user.get_id()) joined_communities=joined_communities(current_user.get_id())
) )

82
app/chat/util.py Normal file
View file

@ -0,0 +1,82 @@
from flask import flash, current_app
from flask_login import current_user
from flask_babel import _
from sqlalchemy import text
from app import db
from app.activitypub.signature import post_request
from app.models import User, ChatMessage, Notification, utcnow, Conversation
from app.utils import allowlist_html, shorten_string, gibberish, markdown_to_html
def send_message(form, conversation_id: int) -> ChatMessage:
conversation = Conversation.query.get(conversation_id)
reply = ChatMessage(sender_id=current_user.id, conversation_id=conversation.id,
body=form.message.data, body_html=allowlist_html(markdown_to_html(form.message.data)))
for recipient in conversation.members:
if recipient.id != current_user.id:
if recipient.is_local():
# Notify local recipient
notify = Notification(title=shorten_string('New message from ' + current_user.display_name()),
url='/chat/' + str(conversation_id),
user_id=recipient.id,
author_id=current_user.id)
db.session.add(notify)
recipient.unread_notifications += 1
db.session.add(reply)
db.session.commit()
else:
db.session.add(reply)
db.session.commit()
# Federate reply
reply_json = {
"actor": current_user.profile_id(),
"id": f"https://{current_app.config['SERVER_NAME']}/activities/create/{gibberish(15)}",
"object": {
"attributedTo": current_user.profile_id(),
"content": reply.body_html,
"id": f"https://{current_app.config['SERVER_NAME']}/private_message/{reply.id}",
"mediaType": "text/html",
"published": utcnow().isoformat() + 'Z', # Lemmy is inconsistent with the date format they use
"source": {
"content": reply.body,
"mediaType": "text/markdown"
},
"to": [
recipient.profile_id()
],
"type": "ChatMessage"
},
"to": [
recipient.profile_id()
],
"type": "Create"
}
success = post_request(recipient.ap_inbox_url, reply_json, current_user.private_key,
current_user.profile_id() + '#main-key')
if not success:
flash(_('Message failed to send to %(name)s.', name=recipient.link()), 'error')
flash(_('Message sent.'))
return reply
def find_existing_conversation(recipient, sender):
sql = """SELECT
c.id AS conversation_id,
c.created_at AS conversation_created_at,
c.updated_at AS conversation_updated_at,
cm1.user_id AS user1_id,
cm2.user_id AS user2_id
FROM
public.conversation AS c
JOIN
public.conversation_member AS cm1 ON c.id = cm1.conversation_id
JOIN
public.conversation_member AS cm2 ON c.id = cm2.conversation_id
WHERE
cm1.user_id = :user_id_1 AND
cm2.user_id = :user_id_2 AND
cm1.user_id <> cm2.user_id;"""
ec = db.session.execute(text(sql), {'user_id_1': recipient.id, 'user_id_2': sender.id}).fetchone()
return Conversation.query.get(ec[0]) if ec else None

View file

@ -256,75 +256,8 @@ def list_files(directory):
@bp.route('/test') @bp.route('/test')
def test(): def test():
u = User.query.get(1)
instance = Instance.query.get(3) return 'ok'
if instance.updated_at < utcnow() - timedelta(days=7):
try:
response = get_request(f'https://{instance.domain}/api/v3/site')
except:
response = None
if response and response.status_code == 200:
try:
instance_data = response.json()
except:
instance_data = None
finally:
response.close()
if instance_data:
if 'admins' in instance_data:
admin_profile_ids = []
for admin in instance_data['admins']:
admin_profile_ids.append(admin['person']['actor_id'].lower())
user = find_actor_or_create(admin['person']['actor_id'])
if user and not instance.user_is_admin(user.id):
new_instance_role = InstanceRole(instance_id=instance.id, user_id=user.id, role='admin')
db.session.add(new_instance_role)
db.session.commit()
# remove any InstanceRoles that are no longer part of instance-data['admins']
for instance_admin in InstanceRole.query.filter_by(instance_id=instance.id):
if instance_admin.user.profile_id() not in admin_profile_ids:
db.session.query(InstanceRole).filter(
InstanceRole.user_id == instance_admin.user.id,
InstanceRole.instance_id == instance.id,
InstanceRole.role == 'admin').delete()
db.session.commit()
return 'Ok'
return ''
retval = ''
for user in User.query.all():
filesize = user.filesize()
num_content = user.num_content()
if filesize > 0 and num_content > 0:
retval += f'{user.id},"{user.ap_id}",{filesize},{num_content}\n'
return retval
return ''
deleted = 0
for user in User.query.all():
if not user.is_local():
if user.cover_id:
file = user.cover
if file.file_path and file.thumbnail_path:
if os.path.exists(file.file_path):
os.unlink(file.file_path)
deleted += 1
file.file_path = ''
db.session.commit()
return str(deleted) + ' done'
return current_app.config['SERVER_NAME']
#ip = request.headers.get('X-Forwarded-For') or request.remote_addr
#if ',' in ip: # Remove all but first ip addresses
# ip = ip[:ip.index(',')].strip()
#return ip
def verification_warning(): def verification_warning():

View file

@ -2,7 +2,7 @@ from datetime import datetime, timedelta, date, timezone
from time import time from time import time
from typing import List from typing import List
from flask import current_app, escape, url_for from flask import current_app, escape, url_for, render_template_string
from flask_login import UserMixin, current_user from flask_login import UserMixin, current_user
from sqlalchemy import or_, text from sqlalchemy import or_, text
from werkzeug.security import generate_password_hash, check_password_hash from werkzeug.security import generate_password_hash, check_password_hash
@ -88,6 +88,62 @@ class InstanceBlock(db.Model):
created_at = db.Column(db.DateTime, default=utcnow) created_at = db.Column(db.DateTime, default=utcnow)
class Conversation(db.Model):
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), index=True)
reported = db.Column(db.Boolean, default=False)
read = db.Column(db.Boolean, default=False)
created_at = db.Column(db.DateTime, default=utcnow)
updated_at = db.Column(db.DateTime, default=utcnow)
initiator = db.relationship('User', backref=db.backref('conversations_initiated', lazy='dynamic'),
foreign_keys=[user_id])
messages = db.relationship('ChatMessage', backref=db.backref('conversation'), cascade='all,delete',
lazy='dynamic')
def member_names(self, user_id):
retval = []
for member in self.members:
if member.id != user_id:
retval.append(member.display_name())
return ', '.join(retval)
def is_member(self, user):
for member in self.members:
if member.id == user.id:
return True
return False
def instances(self):
retval = []
for member in self.members:
if member.instance.id != 1 and member.instance not in retval:
retval.append(member.instance)
return retval
conversation_member = db.Table('conversation_member',
db.Column('user_id', db.Integer, db.ForeignKey('user.id')),
db.Column('conversation_id', db.Integer, db.ForeignKey('conversation.id')),
db.PrimaryKeyConstraint('user_id', 'conversation_id')
)
class ChatMessage(db.Model):
id = db.Column(db.Integer, primary_key=True)
sender_id = db.Column(db.Integer, db.ForeignKey('user.id'), index=True)
recipient_id = db.Column(db.Integer, db.ForeignKey('user.id'), index=True)
conversation_id = db.Column(db.Integer, db.ForeignKey('conversation.id'), index=True)
body = db.Column(db.Text)
body_html = db.Column(db.Text)
reported = db.Column(db.Boolean, default=False)
read = db.Column(db.Boolean, default=False)
encrypted = db.Column(db.String(15))
created_at = db.Column(db.DateTime, default=utcnow)
sender = db.relationship('User', foreign_keys=[sender_id])
class File(db.Model): class File(db.Model):
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
file_path = db.Column(db.String(255)) file_path = db.Column(db.String(255))
@ -386,6 +442,7 @@ class User(UserMixin, db.Model):
avatar = db.relationship('File', lazy='joined', foreign_keys=[avatar_id], single_parent=True, cascade="all, delete-orphan") avatar = db.relationship('File', lazy='joined', foreign_keys=[avatar_id], single_parent=True, cascade="all, delete-orphan")
cover = db.relationship('File', lazy='joined', foreign_keys=[cover_id], single_parent=True, cascade="all, delete-orphan") cover = db.relationship('File', lazy='joined', foreign_keys=[cover_id], single_parent=True, cascade="all, delete-orphan")
instance = db.relationship('Instance', lazy='joined', foreign_keys=[instance_id]) instance = db.relationship('Instance', lazy='joined', foreign_keys=[instance_id])
conversations = db.relationship('Conversation', lazy='dynamic', secondary=conversation_member, backref=db.backref('members', lazy='joined'))
ap_id = db.Column(db.String(255), index=True) # e.g. username@server ap_id = db.Column(db.String(255), index=True) # e.g. username@server
ap_profile_id = db.Column(db.String(255), index=True) # e.g. https://server/u/username ap_profile_id = db.Column(db.String(255), index=True) # e.g. https://server/u/username
@ -959,20 +1016,6 @@ class PostReplyVote(db.Model):
created_at = db.Column(db.DateTime, default=utcnow) created_at = db.Column(db.DateTime, default=utcnow)
class ChatMessage(db.Model):
id = db.Column(db.Integer, primary_key=True)
sender_id = db.Column(db.Integer, db.ForeignKey('user.id'), index=True)
recipient_id = db.Column(db.Integer, db.ForeignKey('user.id'), index=True)
body = db.Column(db.Text)
body_html = db.Column(db.Text)
reported = db.Column(db.Boolean, default=False)
read = db.Column(db.Boolean, default=False)
encrypted = db.Column(db.String(15))
created_at = db.Column(db.DateTime, default=utcnow)
sender = db.relationship('User', foreign_keys=[sender_id])
# save every activity to a log, to aid debugging # save every activity to a log, to aid debugging
class ActivityPubLog(db.Model): class ActivityPubLog(db.Model):
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
@ -1029,18 +1072,19 @@ class Report(db.Model):
reasons = db.Column(db.String(256)) reasons = db.Column(db.String(256))
description = db.Column(db.String(256)) description = db.Column(db.String(256))
status = db.Column(db.Integer, default=0) status = db.Column(db.Integer, default=0)
type = db.Column(db.Integer, default=0) # 0 = user, 1 = post, 2 = reply, 3 = community type = db.Column(db.Integer, default=0) # 0 = user, 1 = post, 2 = reply, 3 = community, 4 = conversation
reporter_id = db.Column(db.Integer, db.ForeignKey('user.id')) reporter_id = db.Column(db.Integer, db.ForeignKey('user.id'))
suspect_community_id = db.Column(db.Integer, db.ForeignKey('user.id')) suspect_community_id = db.Column(db.Integer, db.ForeignKey('user.id'))
suspect_user_id = db.Column(db.Integer, db.ForeignKey('user.id')) suspect_user_id = db.Column(db.Integer, db.ForeignKey('user.id'))
suspect_post_id = db.Column(db.Integer, db.ForeignKey('post.id')) suspect_post_id = db.Column(db.Integer, db.ForeignKey('post.id'))
suspect_post_reply_id = db.Column(db.Integer, db.ForeignKey('post_reply.id')) suspect_post_reply_id = db.Column(db.Integer, db.ForeignKey('post_reply.id'))
suspect_conversation_id = db.Column(db.Integer, db.ForeignKey('conversation.id'))
created_at = db.Column(db.DateTime, default=utcnow) created_at = db.Column(db.DateTime, default=utcnow)
updated = db.Column(db.DateTime, default=utcnow) updated = db.Column(db.DateTime, default=utcnow)
# textual representation of self.type # textual representation of self.type
def type_text(self): def type_text(self):
types = ('User', 'Post', 'Comment', 'Community') types = ('User', 'Post', 'Comment', 'Community', 'Conversation')
if self.type is None: if self.type is None:
return '' return ''
else: else:

View file

@ -18,6 +18,7 @@
<form method="post" enctype="multipart/form-data" id="add_local_user_form"> <form method="post" enctype="multipart/form-data" id="add_local_user_form">
{{ form.csrf_token() }} {{ form.csrf_token() }}
{{ render_field(form.about) }} {{ render_field(form.about) }}
{{ render_field(form.email) }}
{{ render_field(form.matrix_user_id) }} {{ render_field(form.matrix_user_id) }}
{% if user.avatar_id %} {% if user.avatar_id %}
<img class="user_icon_big rounded-circle" src="{{ user.avatar_image() }}" width="120" height="120" /> <img class="user_icon_big rounded-circle" src="{{ user.avatar_image() }}" width="120" height="120" />
@ -30,6 +31,8 @@
{{ render_field(form.banner_file) }} {{ render_field(form.banner_file) }}
<small class="field_hint">Provide a wide image - letterbox orientation.</small> <small class="field_hint">Provide a wide image - letterbox orientation.</small>
{{ render_field(form.bot) }} {{ render_field(form.bot) }}
{{ render_field(form.verified) }}
{{ render_field(form.banned) }}
{{ render_field(form.newsletter) }} {{ render_field(form.newsletter) }}
{{ render_field(form.nsfw) }} {{ render_field(form.nsfw) }}
{{ render_field(form.nsfl) }} {{ render_field(form.nsfl) }}

View file

@ -35,9 +35,11 @@
<td>{{ report.reasons }}</td> <td>{{ report.reasons }}</td>
<td>{{ report.description }}</td> <td>{{ report.description }}</td>
<td>{{ report.type_text() }}</td> <td>{{ report.type_text() }}</td>
<td>{{ report.created_at }}</td> <td>{{ moment(report.created_at).fromNow() }}</td>
<td> <td>
{% if report.suspect_post_reply_id %} {% if report.suspect_conversation_id %}
<a href="/chat/{{ report.suspect_conversation_id }}#message">View</a>
{% elif report.suspect_post_reply_id %}
<a href="/post/{{ report.suspect_post_id }}#comment_{{ report.suspect_post_reply_id }}">View</a> <a href="/post/{{ report.suspect_post_id }}#comment_{{ report.suspect_post_reply_id }}">View</a>
{% elif report.suspect_post_id %} {% elif report.suspect_post_id %}
<a href="/post/{{ report.suspect_post_id }}">View</a> <a href="/post/{{ report.suspect_post_id }}">View</a>

View file

@ -0,0 +1,20 @@
{% if theme() and file_exists('app/templates/themes/' + theme() + '/base.html') %}
{% extends 'themes/' + theme() + '/base.html' %}
{% else %}
{% extends "base.html" %}
{% endif %} %}
{% 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">{{ _('Sorry') }}</div>
<div class="card-body">
<p>{{ _('You have blocked this person or they have blocked you.') }}</p>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View file

@ -10,17 +10,21 @@
<div class="col col-login mx-auto"> <div class="col col-login mx-auto">
<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">{{ _('Options for conversation with "%(sender)s"', sender=sender.link()) }}</div> <div class="card-title">{{ _('Options for conversation with "%(member_names)s"', member_names=conversation.member_names(current_user.id)) }}</div>
<ul class="option_list"> <ul class="option_list">
<li><a href="{{ url_for('chat.chat_delete', sender_id=sender.id) }}" class="no-underline confirm_first" rel="nofollow"><span class="fe fe-delete"></span> <li><a href="{{ url_for('chat.chat_delete', conversation_id=conversation.id) }}" class="no-underline confirm_first" rel="nofollow"><span class="fe fe-delete"></span>
{{ _('Delete conversation') }}</a></li> {{ _('Delete conversation') }}</a></li>
<li><a href="{{ url_for('user.block_profile', actor=sender.link()) }}" class="no-underline"><span class="fe fe-block"></span> {% for member in conversation.members %}
{{ _('Block @%(author_name)s', author_name=sender.display_name()) }}</a></li> {% if member.id != current_user.id %}
{% if sender.instance_id and sender.instance_id != 1 %} <li><a href="{{ url_for('user.block_profile', actor=member.link()) }}" class="no-underline"><span class="fe fe-block"></span>
<li><a href="{{ url_for('chat.block_instance', sender_id=sender.id) }}" class="no-underline"><span class="fe fe-block"></span> {{ _('Block @%(author_name)s', author_name=member.display_name()) }}</a></li>
{{ _("Block everything from instance: %(name)s", name=sender.instance.domain) }}</a></li>
{% endif %} {% endif %}
<li><a href="{{ url_for('chat.chat_report', sender_id=sender.id) }}" class="no-underline" rel="nofollow"><span class="fe fe-report"></span> {% endfor %}
{% for instance in conversation.instances() %}
<li><a href="{{ url_for('chat.block_instance', instance_id=instance.id) }}" class="no-underline"><span class="fe fe-block"></span>
{{ _("Block chats and posts from instance: %(name)s", name=instance.domain) }}</a></li>
{% endfor %}
<li><a href="{{ url_for('chat.chat_report', conversation_id=conversation.id) }}" class="no-underline" rel="nofollow"><span class="fe fe-report"></span>
{{ _('Report to moderators') }}</a></li> {{ _('Report to moderators') }}</a></li>
</ul> </ul>
<p>{{ _('If you are reporting abuse then do not delete the conversation - moderators will not be able to read it if you delete it.') }}</p> <p>{{ _('If you are reporting abuse then do not delete the conversation - moderators will not be able to read it if you delete it.') }}</p>

View file

@ -5,6 +5,22 @@
{% endif %} %} {% endif %} %}
{% from 'bootstrap/form.html' import render_form %} {% from 'bootstrap/form.html' import render_form %}
{% macro conversation_members(conversation) %}
{% if len(conversation.members) == 2 %}
{% for member in conversation.members %}
{% if member.id != current_user.id %}
<img src="{{ member.avatar_thumbnail() }}" loading="lazy" /> {{ member.display_name() }}
{% endif %}
{% endfor %}
{% else %}
{% for member in conversation.members %}
{% if member.id != current_user.id %}
{{ member.display_name() }}
{% endif %}
{% endfor %}
{% endif %}
{% endmacro %}
{% block app_content %} {% block app_content %}
<div class="row"> <div class="row">
<div class="row"> <div class="row">
@ -15,25 +31,17 @@
<li class="breadcrumb-item active">{{ _('Chat') }}</li> <li class="breadcrumb-item active">{{ _('Chat') }}</li>
</ol> </ol>
</nav> </nav>
<h1 class="mt-2">{{ _('Chat') }}</h1> <div class="row mt-3">
<div class="row">
<div class="col col-md-2 d-none d-md-block sender_list"> <div class="col col-md-2 d-none d-md-block sender_list">
<h3>{{ _('People') }}</h3> <h3>{{ _('People') }}</h3>
<ul class="list-group list-group-flush"> <ul class="list-group list-group-flush">
{% for sender in senders %} {% for conversation in conversations %}
<li class="list-group-item"> <li class="list-group-item">
{% if other_party %} {% if conversation.id == current_conversation %}
{% if other_party.id != sender.id %} {{ conversation_members(conversation) }}
<a href="{{ url_for('chat.chat_home', sender_id=sender.id) }}">{% if sender.avatar_image() %}<img src="{{ sender.avatar_image() }}" class="community_icon rounded-circle" loading="lazy" alt="" />{% endif %}
{{ sender.link() }}
</a>
{% else %} {% else %}
{% if sender.avatar_image() %}<img src="{{ sender.avatar_image() }}" class="community_icon rounded-circle" loading="lazy" alt="" />{% endif %} <a href="{{ url_for('chat.chat_home', conversation_id=conversation.id) }}">
{{ sender.link() }} {{ conversation_members(conversation) }}
{% endif %}
{% else %}
<a href="{{ url_for('chat.chat_home', sender_id=sender.id) }}">{% if sender.avatar_image() %}<img src="{{ sender.avatar_image() }}" class="community_icon rounded-circle" loading="lazy" alt="" />{% endif %}
{{ sender.link() }}
</a> </a>
{% endif %} {% endif %}
</li> </li>
@ -41,24 +49,24 @@
</ul> </ul>
</div> </div>
<div class="col col-md-10"> <div class="col col-md-10">
{% if other_party %} {% if messages %}
<h3 class="d-none d-md-inline">{{ _('Messages with %(name)s', name=other_party.display_name()) }}</h3> <h3 class="d-none d-md-inline">{{ _('Messages with %(name)s', name=conversation.member_names(current_user.id)) }}</h3>
<h3 class="d-md-none">{{ _('Messages with: ') }} <h3 class="d-md-none">{{ _('Messages with: ') }}
<select id="changeSender">{% for sender in senders %} <select id="changeSender">{% for conversation in conversations %}
<option value="{{ sender.id }}" {{ 'selected' if sender.id == other_party.id }}>{{ sender.link() }}</option> <option value="{{ conversation.id }}" {{ 'selected' if conversation.id == current_conversation }}>{{ conversation.member_names(current_user.id) }}</option>
{% endfor %} {% endfor %}
</select></h3> </select></h3>
<div class="conversation"> <div class="conversation mt-3">
{% for message in messages %} {% for message in messages %}
<div id="message_{{ message.id }}" class="card message {{ 'from_other_party' if message.sender_id != current_user.id else 'from_me' }}"> <div id="message_{{ message.id }}" class="card message {{ 'from_other_party' if message.sender_id != current_user.id else 'from_me' }}">
<div class="message_body"> <div class="message_body">
<span class="message_created_at text-muted small">{{ moment(message.created_at).fromNow(refresh=True) }}</span> <span class="message_created_at text-muted small">{{ moment(message.created_at).fromNow(refresh=True) }}</span>
<span class="message_sender">{{ message.sender.display_name() }}</span>: {{ message.body_html|safe }} <span class="message_sender"><a href="/u/{{ message.sender.link() }}">{{ message.sender.display_name() }}</a></span>: {{ message.body_html|safe }}
</div> </div>
</div> </div>
{% endfor %} {% endfor %}
{{ render_form(form) }} {{ render_form(form) }}
<a class="conversation_options btn btn-outline-secondary" href="{{ url_for('chat.chat_options', sender_id=other_party.id) }}" class="btn btn-outline-secondary">{{ _('Options') }}</a> <a class="conversation_options btn btn-outline-secondary" href="{{ url_for('chat.chat_options', conversation_id=current_conversation) }}" class="btn btn-outline-secondary">{{ _('Options') }}</a>
</div> </div>
{% endif %} {% endif %}
</div> </div>

View file

@ -0,0 +1,21 @@
{% 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">{{ _('Sorry') }}</div>
<div class="card-body">
<p>{{ _('You have not been using PieFed long enough to be allowed to send messages to people.') }}</p>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,21 @@
{% 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">{{ _('New message to "%(recipient_name)s"', recipient_name=recipient.link()) }}</div>
<div class="card-body">
{{ render_form(form) }}
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,21 @@
{% 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">{{ _('Report conversation with "%(member_names)s"', member_names=conversation.member_names(current_user.id)) }}</div>
<div class="card-body">
{{ render_form(form) }}
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View file

@ -41,12 +41,10 @@
{% endif %} {% endif %}
{% if current_user.is_authenticated %} {% if current_user.is_authenticated %}
<div class="profile_action_buttons"> <div class="profile_action_buttons">
{% if not current_user.created_recently() and current_user.reputation > 10 %}
<a class="btn btn-primary" href="{{ url_for('chat.new_message', to=user.id) }}" rel="nofollow" aria-label="{{ _('Send message') }}">{{ _('Send message') }}</a> <a class="btn btn-primary" href="{{ url_for('chat.new_message', to=user.id) }}" rel="nofollow" aria-label="{{ _('Send message') }}">{{ _('Send message') }}</a>
{% if user.matrix_user_id %} {% if user.matrix_user_id %}
<a class="btn btn-primary" href="https://matrix.to/#/{{ user.matrix_user_id }}" rel="nofollow" aria-label="{{ _('Send message with matrix chat') }}">{{ _('Send message using Matrix') }}</a> <a class="btn btn-primary" href="https://matrix.to/#/{{ user.matrix_user_id }}" rel="nofollow" aria-label="{{ _('Send message with matrix chat') }}">{{ _('Send message using Matrix') }}</a>
{% endif %} {% endif %}
{% endif %}
{% if current_user.id != user.id %} {% if current_user.id != user.id %}
{% if current_user.has_blocked_user(user.id) %} {% if current_user.has_blocked_user(user.id) %}
<a class="btn btn-primary" href="{{ url_for('user.unblock_profile', actor=user.link()) }}" rel="nofollow">{{ _('Unblock user') }}</a> <a class="btn btn-primary" href="{{ url_for('user.unblock_profile', actor=user.link()) }}" rel="nofollow">{{ _('Unblock user') }}</a>

View file

@ -389,7 +389,7 @@ def user_cookie_banned() -> bool:
return cookie is not None return cookie is not None
@cache.memoize(timeout=300) @cache.memoize(timeout=30)
def banned_ip_addresses() -> List[str]: def banned_ip_addresses() -> List[str]:
ips = IpBan.query.all() ips = IpBan.query.all()
return [ip.ip_address for ip in ips] return [ip.ip_address for ip in ips]

View file

@ -0,0 +1,34 @@
"""chat reporting
Revision ID: 8ca0f0789040
Revises: b4f7322082f4
Create Date: 2024-02-19 14:58:13.481708
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '8ca0f0789040'
down_revision = 'b4f7322082f4'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('report', schema=None) as batch_op:
batch_op.add_column(sa.Column('suspect_conversation_id', sa.Integer(), nullable=True))
batch_op.create_foreign_key(None, 'conversation', ['suspect_conversation_id'], ['id'])
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('report', schema=None) as batch_op:
batch_op.drop_constraint(None, type_='foreignkey')
batch_op.drop_column('suspect_conversation_id')
# ### end Alembic commands ###

View file

@ -0,0 +1,61 @@
"""chat conversations
Revision ID: b4f7322082f4
Revises: fe1e3fbf5b9d
Create Date: 2024-02-18 14:54:20.090872
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'b4f7322082f4'
down_revision = 'fe1e3fbf5b9d'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('conversation',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=True),
sa.Column('reported', sa.Boolean(), nullable=True),
sa.Column('read', sa.Boolean(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('id')
)
with op.batch_alter_table('conversation', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_conversation_user_id'), ['user_id'], unique=False)
op.create_table('conversation_member',
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('conversation_id', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['conversation_id'], ['conversation.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('user_id', 'conversation_id')
)
with op.batch_alter_table('chat_message', schema=None) as batch_op:
batch_op.add_column(sa.Column('conversation_id', sa.Integer(), nullable=True))
batch_op.create_index(batch_op.f('ix_chat_message_conversation_id'), ['conversation_id'], unique=False)
batch_op.create_foreign_key(None, 'conversation', ['conversation_id'], ['id'])
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('chat_message', schema=None) as batch_op:
batch_op.drop_constraint(None, type_='foreignkey')
batch_op.drop_index(batch_op.f('ix_chat_message_conversation_id'))
batch_op.drop_column('conversation_id')
op.drop_table('conversation_member')
with op.batch_alter_table('conversation', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_conversation_user_id'))
op.drop_table('conversation')
# ### end Alembic commands ###