diff --git a/files/classes/__init__.py b/files/classes/__init__.py index d8a8ab6c4..5d6a2cfef 100644 --- a/files/classes/__init__.py +++ b/files/classes/__init__.py @@ -30,3 +30,5 @@ from .transactions import * from .sub_logs import * from .media import * from .push_subscriptions import * +from .group import * +from .group_membership import * diff --git a/files/classes/group.py b/files/classes/group.py new file mode 100644 index 000000000..64e827f89 --- /dev/null +++ b/files/classes/group.py @@ -0,0 +1,45 @@ +import time + +from sqlalchemy import Column +from sqlalchemy.orm import relationship +from sqlalchemy.types import Integer + +from files.classes import Base +from files.helpers.lazy import lazy +from files.helpers.config.const import * + +from .group_membership import * + +class Group(Base): + __tablename__ = "groups" + name = Column(String, primary_key=True) + created_utc = Column(Integer) + + memberships = relationship("GroupMembership", primaryjoin="GroupMembership.group_name==Group.name", order_by="GroupMembership.created_utc") + + def __init__(self, *args, **kwargs): + if "created_utc" not in kwargs: kwargs["created_utc"] = int(time.time()) + super().__init__(*args, **kwargs) + + def __repr__(self): + return self.name + + @property + @lazy + def owner(self): + return self.memberships[0].user + + @property + @lazy + def members(self): + return [x.user for x in self.memberships if x.approved_utc] + + @property + @lazy + def member_ids(self): + return [x.id for x in self.members] + + @property + @lazy + def applied_ids(self): + return [x.user_id for x in self.memberships if not x.approved_utc] diff --git a/files/classes/group_membership.py b/files/classes/group_membership.py new file mode 100644 index 000000000..177b6dec0 --- /dev/null +++ b/files/classes/group_membership.py @@ -0,0 +1,23 @@ +import time + +from sqlalchemy import Column, ForeignKey +from sqlalchemy.orm import relationship +from sqlalchemy.types import Integer, String + +from files.classes import Base + +class GroupMembership(Base): + __tablename__ = "group_memberships" + user_id = Column(Integer, ForeignKey("users.id"), primary_key=True) + group_name = Column(String, ForeignKey("groups.name"), primary_key=True) + created_utc = Column(Integer) + approved_utc = Column(Integer) + + user = relationship("User", uselist=False) + + def __init__(self, *args, **kwargs): + if "created_utc" not in kwargs: kwargs["created_utc"] = int(time.time()) + super().__init__(*args, **kwargs) + + def __repr__(self): + return f"<{self.__class__.__name__}(user_id={self.user_id}, group={self.group})>" diff --git a/files/helpers/alerts.py b/files/helpers/alerts.py index 7b1338de3..3625cdcd6 100644 --- a/files/helpers/alerts.py +++ b/files/helpers/alerts.py @@ -5,7 +5,7 @@ import gevent from flask import g from pywebpush import webpush -from files.classes import Comment, Notification, PushSubscription +from files.classes import Comment, Notification, PushSubscription, Group from .config.const import * from .regex import * @@ -126,10 +126,13 @@ def NOTIFY_USERS(text, v): if word in text and id not in notify_users: notify_users.add(id) - if v.id != AEVANN_ID and '!biofoids' in text and SITE == 'rdrama.net': - if v.id not in BIOFOIDS: - abort(403, "Only members of the ping group can ping it!") - notify_users.update(BIOFOIDS) + if FEATURES['PING_GROUPS']: + for i in group_mention_regex.finditer(text): + group = g.db.get(Group, i.group(2)) + if group: + if v.id not in group.member_ids: + abort(403, "Only members of the ping group can ping it!") + notify_users.update(group.member_ids) names = set(m.group(2) for m in mention_regex.finditer(text)) for user in get_users(names, graceful=True): diff --git a/files/helpers/config/const.py b/files/helpers/config/const.py index 1a5a2fcd3..76978950f 100644 --- a/files/helpers/config/const.py +++ b/files/helpers/config/const.py @@ -517,6 +517,7 @@ FEATURES = { 'PATRON_ICONS': False, 'ASSET_SUBMISSIONS': False, 'NSFW_MARKING': True, + 'PING_GROUPS': True, } WERKZEUG_ERROR_DESCRIPTIONS = { @@ -621,6 +622,7 @@ HOLE_NAME = 'hole' HOLE_STYLE_FLAIR = False HOLE_REQUIRED = False HOLE_COST = 0 +GROUP_COST = 10000 HOLE_INACTIVITY_DELETION = False PRIVILEGED_USER_BOTS = () @@ -1043,5 +1045,3 @@ AUDIO_FORMATS = ('mp3','wav','ogg','aac','m4a','flac') if not IS_LOCALHOST and SECRET_KEY == DEFAULT_CONFIG_VALUE: from warnings import warn warn("Secret key is the default value! Please change it to a secure random number. Thanks <3", RuntimeWarning) - -BIOFOIDS = {2654,12966,10682,11375,18523,2432,1054,2054,15869,18339,2113,114,35} diff --git a/files/helpers/regex.py b/files/helpers/regex.py index 8a8358c52..efce7b8c8 100644 --- a/files/helpers/regex.py +++ b/files/helpers/regex.py @@ -7,7 +7,9 @@ from .config.const import * valid_username_chars = 'a-zA-Z0-9_\-' valid_username_regex = re.compile("^[a-zA-Z0-9_\-]{3,25}$", flags=re.A) -mention_regex = re.compile('(^|\s|>)@(([a-zA-Z0-9_\-]){1,30})(?![^<]*<\/(code|pre|a)>)', flags=re.A) +mention_regex = re.compile('(^|\s|>)@([a-zA-Z0-9_\-]{1,30})(?![^<]*<\/(code|pre|a)>)', flags=re.A) + +group_mention_regex = re.compile('(^|\s|>)!([a-z0-9_\-]{3,25})(?![^<]*<\/(code|pre|a)>)', flags=re.A) valid_password_regex = re.compile("^.{8,100}$", flags=re.A) diff --git a/files/routes/__init__.py b/files/routes/__init__.py index 3379152bb..55c8148b4 100644 --- a/files/routes/__init__.py +++ b/files/routes/__init__.py @@ -47,3 +47,5 @@ if FEATURES['ASSET_SUBMISSIONS']: from .asset_submissions import * from .special import * from .push_notifs import * +if FEATURES['PING_GROUPS']: + from .groups import * diff --git a/files/routes/groups.py b/files/routes/groups.py new file mode 100644 index 000000000..1f5a95583 --- /dev/null +++ b/files/routes/groups.py @@ -0,0 +1,115 @@ +from files.classes import * +from files.helpers.alerts import * +from files.helpers.regex import * +from files.helpers.get import * + +from files.routes.wrappers import * + +from files.__main__ import app, limiter + +@app.get("/ping_groups") +@limiter.limit(DEFAULT_RATELIMIT, key_func=get_ID) +@auth_required +def ping_groups(v:User): + groups = g.db.query(Group).order_by(Group.created_utc).all() + return render_template('groups.html', v=v, groups=groups, cost=GROUP_COST, msg=get_msg(), error=get_error()) + +@app.post("/create_group") +@limiter.limit(DEFAULT_RATELIMIT, key_func=get_ID) +@is_not_permabanned +def create_group(v): + name = request.values.get('name') + if not name: abort(400) + name = name.strip().lower() + + if not valid_sub_regex.fullmatch(name): + return redirect(f"/ping_groups?error=Name does not match the required format!") + + group = g.db.get(Group, name) + if not group: + if not v.charge_account('coins', GROUP_COST): + return redirect(f"/ping_groups?error=You don't have enough coins!") + + g.db.add(v) + if v.shadowbanned: abort(500) + + group = Group(name=name) + g.db.add(group) + g.db.flush() + + group_membership = GroupMembership( + user_id=v.id, + group_name=group.name, + created_utc=time.time(), + approved_utc=time.time() + ) + g.db.add(group_membership) + + admins = [x[0] for x in g.db.query(User.id).filter(User.admin_level >= PERMS['NOTIFICATIONS_HOLE_CREATION'], User.id != v.id).all()] + for admin in admins: + send_repeatable_notification(admin, f":!marseyparty: !{group} has been created by @{v.username} :marseyparty:") + + return redirect(f'/ping_groups?msg=!{group} created successfully!') + +@app.post("/!/join") +@limiter.limit(DEFAULT_RATELIMIT, key_func=get_ID) +@auth_required +def join_group(v:User, group_name): + group = g.db.get(Group, group_name) + if not group: abort(404) + existing = g.db.query(GroupMembership).filter_by(user_id=v.id, group_name=group_name).one_or_none() + if not existing: + join = GroupMembership(user_id=v.id, group_name=group_name) + g.db.add(join) + send_repeatable_notification(group.owner.id, f"@{v.username} has applied to join !{group}. You can approve or reject the application [here](/!{group}/applications).") + + return redirect(f"/ping_groups?msg=Application submitted to !{group}'s owner (@{group.owner.username}) successfully!") + +@app.get("/!/applications") +@limiter.limit(DEFAULT_RATELIMIT, key_func=get_ID) +@auth_required +def applications(v:User, group_name): + group = g.db.get(Group, group_name) + if not group: abort(404) + applications = g.db.query(GroupMembership).filter_by(group_name=group_name, approved_utc=None).order_by(GroupMembership.created_utc.desc()).all() + return render_template('group_applications.html', v=v, group=group, applications=applications, msg=get_msg()) + +@app.post("/!//approve") +@limiter.limit(DEFAULT_RATELIMIT, key_func=get_ID) +@auth_required +def group_approve(v:User, group_name, user_id): + group = g.db.get(Group, group_name) + if not group: abort(404) + + if v.id != group.owner.id: + abort(403, f"Only the group owner (@{group.owner.username}) can approve applications!") + + application = g.db.query(GroupMembership).filter_by(user_id=user_id, group_name=group.name).one_or_none() + if not application: + abort(404, "There is no application to approve!") + + if not application.approved_utc: + application.approved_utc = time.time() + g.db.add(application) + send_repeatable_notification(application.user_id, f"@{v.username} (!{group}'s owner) has approved your application!") + + return redirect(f'/!{group}/applications?msg=@{application.user.username} has been approved successfully!') + +@app.post("/!//reject") +@limiter.limit(DEFAULT_RATELIMIT, key_func=get_ID) +@auth_required +def group_reject(v:User, group_name, user_id): + group = g.db.get(Group, group_name) + if not group: abort(404) + + if v.id != group.owner.id: + abort(403, f"Only the group owner (@{group.owner.username}) can reject applications!") + + application = g.db.query(GroupMembership).filter_by(user_id=user_id, group_name=group.name).one_or_none() + if not application: + abort(404, "There is no application to reject!") + + g.db.delete(application) + send_repeatable_notification(application.user_id, f"@{v.username} (!{group}'s owner) has rejected your application!") + + return redirect(f'/!{group}/applications?msg=@{application.user.username} has been rejected successfully!') diff --git a/files/templates/group_applications.html b/files/templates/group_applications.html new file mode 100644 index 000000000..ff1dc72c3 --- /dev/null +++ b/files/templates/group_applications.html @@ -0,0 +1,44 @@ +{% extends "default.html" %} +{% block pagetitle %}!{{group}} Applications{% endblock %} +{% block content %} +{% if error %}{{macros.alert(error, true)}}{% endif %} +{% if msg %}{{macros.alert(msg, false)}}{% endif %} +
!{{group}} Applications
+
+ + + + + {% if v.id == group.owner.id %} + + + {% endif %} + + + +{% for application in applications %} + + + + {% if v.id == group.owner.id %} + + {% endif %} + +{% endfor %} + +
NameApplied on
+ {% with user=application.user %} + {% include "user_in_table.html" %} + {% endwith %} + +
+ + +
+
+ + +
+
+ +{% endblock %} diff --git a/files/templates/groups.html b/files/templates/groups.html new file mode 100644 index 000000000..a8b1ac49f --- /dev/null +++ b/files/templates/groups.html @@ -0,0 +1,76 @@ +{% extends "default.html" %} +{% block pagetitle %}Ping Groups{% endblock %} +{% block content %} +{% if error %}{{macros.alert(error, true)}}{% endif %} +{% if msg %}{{macros.alert(msg, false)}}{% endif %} +
+

Ping Groups

+
+ + + + + + + + + + + + {% for group in groups %} + + + + + + + + {% endfor %} +
#NameMembersCreated on
{{loop.index}}{{group.name}} + {% for user in group.members %} + + {% include "user_in_table.html" %} + + {% endfor %} + + {% if v.id in group.applied_ids %} + Application pending + {% elif v.id not in group.member_ids %} +
+ + +
+ {% else %} + Applications + {% endif %} +
+
+ +

Create Ping Group

+
+
+
+
+
+ + + + 3-25 characters, including letters, numbers, _ , and - + +
+
+
+
+
+
+{% endblock %} diff --git a/files/templates/megathread_index.html b/files/templates/megathread_index.html index a0b69a784..ba9596223 100644 --- a/files/templates/megathread_index.html +++ b/files/templates/megathread_index.html @@ -7,6 +7,12 @@ {%- if SITE_NAME == 'rDrama' -%} {%- do MEGATHREAD_INDEX.extend([ + ( + 'Ping Groups', + 'List of ping groups', + 'fa-users', '#dc3545', + '/ping_groups', + ), ( 'Bugs / Suggestions', 'Something broken? Improvements?', diff --git a/files/templates/sidebar_rDrama.html b/files/templates/sidebar_rDrama.html index 30e0cf5db..55988e35f 100644 --- a/files/templates/sidebar_rDrama.html +++ b/files/templates/sidebar_rDrama.html @@ -52,7 +52,7 @@ {% else %} DIRECTORY - Submit Marseys & Art | Info Megathreads + Ping Groups | Submit Marseys & Art | Info Megathreads BROWSE {{HOLE_NAME|upper}}S {% if v and v.can_create_hole -%} diff --git a/files/templates/user_in_table.html b/files/templates/user_in_table.html index 67a55f534..b09e21b2e 100644 --- a/files/templates/user_in_table.html +++ b/files/templates/user_in_table.html @@ -1,7 +1,7 @@ {%- include 'admin/shadowbanned_tooltip.html' -%} {% if user %} -
+
{% if user.hat_active(v)[0] -%} diff --git a/migrations/20230224-ping-groups.sql b/migrations/20230224-ping-groups.sql new file mode 100644 index 000000000..5e7a6610d --- /dev/null +++ b/migrations/20230224-ping-groups.sql @@ -0,0 +1,22 @@ +create table groups ( + name character varying(25) primary key, + created_utc integer not null +); + +create index groups_index on groups using btree (created_utc asc); + +create table group_memberships ( + user_id integer not null, + group_name varchar(25) not null, + created_utc integer not null, + approved_utc integer +); + +alter table only group_memberships + add constraint group_memberships_pkey primary key (user_id, group_name); + +alter table only group_memberships + add constraint group_memberships_user_fkey foreign key (user_id) references public.users(id); + +alter table only group_memberships + add constraint group_memberships_group_fkey foreign key (group_name) references public.groups(name);