1+ from CTFd .plugins .challenges import BaseChallenge
2+ from CTFd .utils .modes import TEAMS_MODE , get_mode_as_word , get_model
3+ from CTFd .utils .decorators import admins_only
4+ from CTFd .utils .humanize .numbers import ordinalize
5+ from CTFd .utils import get_config , set_config
6+ from CTFd .cache import clear_config
7+ from CTFd .models import Challenges , Solves , db
8+ from flask import url_for , Blueprint , render_template , redirect , request , session , abort , Markup
9+ from functools import wraps
10+ import requests
11+ import uuid
12+
13+ class BaseNotifier (object ):
14+ def get_settings (self ):
15+ return []
16+ def is_configured (self ):
17+ return True
18+ def notify_solve (self , format , solver_name , solver_url , challenge_name , challenge_url , solve_num ):
19+ pass
20+ def notify_message (self , title , content ):
21+ pass
22+
23+ class SlackNotifier (BaseNotifier ):
24+ def get_settings (self ):
25+ return ['notifier_slack_webhook_url' ]
26+ def get_webhook_url (self ):
27+ return get_config ('notifier_slack_webhook_url' )
28+ def is_configured (self ):
29+ return bool (self .get_webhook_url ())
30+
31+ def notify_solve (self , format , solver_name , solver_url , challenge_name , challenge_url , solve_num ):
32+ plain_msg = format .format (
33+ solver = solver_name ,
34+ challenge = challenge_name ,
35+ solve_num = ordinalize (solve_num ),
36+ )
37+ markdown_msg = format .format (
38+ solver = '<{solver_url}|{solver_name}>' .format (solver_name = solver_name , solver_url = solver_url ),
39+ challenge = '<{challenge_url}|{challenge_name}>' .format (challenge_name = challenge_name , challenge_url = challenge_url ),
40+ solve_num = ordinalize (solve_num ),
41+ )
42+
43+ requests .post (self .get_webhook_url (), json = {
44+ 'text' : plain_msg ,
45+ 'blocks' : [
46+ {
47+ "type" : "section" ,
48+ "text" : {
49+ "type" : "mrkdwn" ,
50+ "text" : markdown_msg
51+ }
52+ },
53+ ]
54+ })
55+
56+ def notify_message (self , title , content ):
57+ requests .post (self .get_webhook_url (), json = {
58+ 'text' : content ,
59+ 'blocks' : [
60+ {
61+ "type" : "header" ,
62+ "text" : {
63+ "type" : "plain_text" ,
64+ "text" : title
65+ }
66+ },
67+ {
68+ "type" : "section" ,
69+ "text" : {
70+ "type" : "mrkdwn" ,
71+ "text" : content
72+ }
73+ },
74+ ]
75+ })
76+
77+ class DiscordNotifier (BaseNotifier ):
78+ def get_settings (self ):
79+ return ['notifier_discord_webhook_url' ]
80+ def get_webhook_url (self ):
81+ return get_config ('notifier_discord_webhook_url' )
82+ def is_configured (self ):
83+ return bool (self .get_webhook_url ())
84+
85+ def notify_solve (self , format , solver_name , solver_url , challenge_name , challenge_url , solve_num ):
86+ markdown_msg = format .format (
87+ solver = '[{solver_name}]({solver_url})' .format (solver_name = solver_name , solver_url = solver_url ),
88+ challenge = '[{challenge_name}]({challenge_url})' .format (challenge_name = challenge_name , challenge_url = challenge_url ),
89+ solve_num = ordinalize (solve_num ),
90+ )
91+
92+ requests .post (self .get_webhook_url (), json = {
93+ 'embeds' : [{
94+ 'description' : markdown_msg ,
95+ }]
96+ })
97+
98+ def notify_message (self , title , content ):
99+ requests .post (self .get_webhook_url (), json = {
100+ 'embeds' : [{
101+ 'title' : title ,
102+ 'description' : content ,
103+ }]
104+ })
105+
106+ class TelegramNotifier (BaseNotifier ):
107+ def get_settings (self ):
108+ return ['notifier_telegram_bot_token' , 'notifier_telegram_chat_id' ]
109+ def get_bot_token (self ):
110+ return get_config ('notifier_telegram_bot_token' )
111+ def get_chat_id (self ):
112+ return get_config ('notifier_telegram_chat_id' )
113+ def is_configured (self ):
114+ return bool (self .get_bot_token ()) and bool (self .get_chat_id ())
115+
116+ @staticmethod
117+ def _escape (s ):
118+ badchars = ['\\ ' , '_' , '*' , '[' , ']' , '(' , ')' , '~' , '`' , '>' , '#' , '+' , '-' , '=' , '|' , '{' , '}' , '.' , '!' ]
119+ for char in badchars :
120+ s = s .replace (char , '\\ ' + char )
121+ return s
122+
123+ def notify_solve (self , format , solver_name , solver_url , challenge_name , challenge_url , solve_num ):
124+ markdown_msg = format .replace ('(' , '\\ (' ).replace (')' , '\\ )' ).format (
125+ solver = '[{solver_name}]({solver_url})' .format (solver_name = self ._escape (solver_name ), solver_url = self ._escape (solver_url )),
126+ challenge = '[{challenge_name}]({challenge_url})' .format (challenge_name = self ._escape (challenge_name ), challenge_url = self ._escape (challenge_url )),
127+ solve_num = ordinalize (solve_num ),
128+ )
129+
130+ requests .post ('https://api.telegram.org/bot{bot_token}/sendMessage' .format (bot_token = self .get_bot_token ()), json = {
131+ 'chat_id' : self .get_chat_id (),
132+ 'parse_mode' : 'MarkdownV2' ,
133+ 'text' : markdown_msg ,
134+ })
135+
136+ def notify_message (self , title , content ):
137+ requests .post ('https://api.telegram.org/bot{bot_token}/sendMessage' .format (bot_token = self .get_bot_token ()), json = {
138+ 'chat_id' : self .get_chat_id (),
139+ 'parse_mode' : 'MarkdownV2' ,
140+ 'text' : '*{title}*\n {content}' .format (title = self ._escape (title ), content = self ._escape (content )),
141+ })
142+
143+ class MatrixNotifier (BaseNotifier ):
144+ def get_settings (self ):
145+ return ['notifier_matrix_bot_token' , 'notifier_matrix_room_id' , 'notifier_matrix_server_url' ]
146+ def get_bot_token (self ):
147+ return get_config ('notifier_matrix_bot_token' )
148+ def get_room_id (self ):
149+ return get_config ('notifier_matrix_room_id' )
150+ def get_server_url (self ):
151+ return get_config ('notifier_matrix_server_url' )
152+ def is_configured (self ):
153+ return bool (self .get_bot_token ()) and bool (self .get_room_id ()) and bool (self .get_server_url ())
154+
155+ @staticmethod
156+ def _escape (s ):
157+ badchars = ['\\ ' , '_' , '*' , '[' , ']' , '(' , ')' , '~' , '`' , '>' , '#' , '+' , '-' , '=' , '|' , '{' , '}' , '.' , '!' ]
158+ for char in badchars :
159+ s = s .replace (char , '\\ ' + char )
160+ return s
161+
162+ def notify_solve (self , format , solver_name , solver_url , challenge_name , challenge_url , solve_num ):
163+
164+ markdown_msg = format .replace ('(' , '\\ (' ).replace (')' , '\\ )' ).format (
165+ solver = '[{solver_name}]({solver_url})' .format (solver_name = self ._escape (solver_name ), solver_url = self ._escape (solver_url )),
166+ challenge = '[{challenge_name}]({challenge_url})' .format (challenge_name = self ._escape (challenge_name ), challenge_url = self ._escape (challenge_url )),
167+ solve_num = ordinalize (solve_num ),
168+ )
169+ plain_msg = format .format (
170+ solver = solver_name ,
171+ challenge = challenge_name ,
172+ solve_num = ordinalize (solve_num ),
173+ )
174+
175+ headers = {"Authorization" : f"Bearer { self .get_bot_token ()} " }
176+ payload = {
177+ "msgtype" : "m.text" ,
178+ "body" : plain_msg
179+ }
180+
181+ txn_id = str (uuid .uuid4 ())
182+ path = f"/_matrix/client/r0/rooms/{ self .get_room_id ()} /send/m.room.message/{ txn_id } "
183+
184+ requests .put (f'{ self .get_server_url ()} { path } ' , json = payload , headers = headers )
185+
186+ def notify_message (self , title , content ):
187+ payload = {
188+ "msgtype" : "m.text" ,
189+ "body" : '*{title}*\n {content}' .format (title = self ._escape (title ), content = self ._escape (content ))
190+ }
191+
192+ headers = {
193+ "Authorization" : f"Bearer { self .get_bot_token ()} "
194+ }
195+
196+ txn_id = str (uuid .uuid4 ())
197+ path = f"/_matrix/client/r0/rooms/{ self .get_room_id ()} /send/m.room.message/{ txn_id } "
198+
199+ requests .put (f'{ self .get_server_url ()} { path } ' , json = payload , headers = headers )
200+
201+ """
202+ Global dictionary used to hold all the supported chat services. To add support for a new chat service, create a plugin and insert
203+ your BaseNotifier subclass instance into this dictionary to register it.
204+ """
205+ NOTIFIER_CLASSES = {"slack" : SlackNotifier (), "discord" : DiscordNotifier (), "telegram" : TelegramNotifier (), "matrix" : MatrixNotifier ()}
206+
207+ def get_configured_notifier ():
208+ notifier_type = get_config ('notifier_type' )
209+ if not notifier_type :
210+ return None
211+ notifier = NOTIFIER_CLASSES [notifier_type ]
212+ if not notifier .is_configured ():
213+ return None
214+ return notifier
215+
216+ def get_all_notifier_settings ():
217+ settings = set ()
218+ for k ,v in NOTIFIER_CLASSES .items ():
219+ for setting in v .get_settings ():
220+ if setting in settings :
221+ raise Exception ('Notifier {0} uses duplicate setting name {1}' , v , setting )
222+ settings .add (setting )
223+ return settings
224+
225+ def load (app ):
226+ chat_notifier = Blueprint ('chat_notifier' , __name__ , template_folder = 'templates' )
227+
228+ @chat_notifier .route ('/admin/chat_notifier' , methods = ['GET' , 'POST' ])
229+ @admins_only
230+ def chat_notifier_admin ():
231+ clear_config ()
232+ if request .method == "POST" :
233+ if request .form ['notifier_type' ] and request .form ['notifier_type' ] not in NOTIFIER_CLASSES .keys ():
234+ abort (400 )
235+ set_config ('notifier_type' , request .form ['notifier_type' ])
236+ set_config ('notifier_send_notifications' , 'notifier_send_notifications' in request .form )
237+ set_config ('notifier_send_solves' , 'notifier_send_solves' in request .form )
238+ set_config ('notifier_solve_msg' , request .form ['notifier_solve_msg' ])
239+ if request .form ['notifier_solve_count' ]:
240+ set_config ('notifier_solve_count' , int (request .form ['notifier_solve_count' ]))
241+ else :
242+ set_config ('notifier_solve_count' , None )
243+ for setting in get_all_notifier_settings ():
244+ set_config (setting , request .form [setting ])
245+ return redirect (url_for ('chat_notifier.chat_notifier_admin' ))
246+ else :
247+ context = {
248+ 'nonce' : session ['nonce' ],
249+ 'supported_notifier_types' : NOTIFIER_CLASSES .keys (),
250+ 'notifier_type' : get_config ('notifier_type' ),
251+ 'notifier_send_notifications' : get_config ('notifier_send_notifications' ),
252+ 'notifier_send_solves' : get_config ('notifier_send_solves' ),
253+ 'notifier_solve_msg' : get_config ('notifier_solve_msg' ),
254+ 'notifier_solve_count' : get_config ('notifier_solve_count' ),
255+ }
256+ for setting in get_all_notifier_settings ():
257+ context [setting ] = get_config (setting )
258+ supported_notifier_settings = {}
259+ for k ,v in NOTIFIER_CLASSES .items ():
260+ supported_notifier_settings [k ] = Markup (render_template ('chat_notifier/admin_notifier_settings/{}.html' .format (k ), ** context ))
261+ context ['supported_notifier_settings' ] = supported_notifier_settings
262+ return render_template ('chat_notifier/admin.html' , ** context )
263+
264+ app .register_blueprint (chat_notifier )
265+
266+ def chal_solve_decorator (chal_solve_func ):
267+ @wraps (chal_solve_func )
268+ def wrapper (user , team , challenge , request ):
269+ chal_solve_func (user , team , challenge , request )
270+
271+ notifier = get_configured_notifier ()
272+ if notifier and bool (get_config ('notifier_send_solves' )):
273+ if get_mode_as_word () == TEAMS_MODE :
274+ solver = team
275+ solver_url = url_for ("teams.public" , team_id = solver .account_id , _external = True )
276+ else :
277+ solver = user
278+ solver_url = url_for ("users.public" , user_id = solver .account_id , _external = True )
279+ challenge_url = url_for ('challenges.listing' , _external = True , _anchor = '{challenge.name}-{challenge.id}' .format (challenge = challenge ))
280+
281+ Model = get_model ()
282+ solve_count = (
283+ db .session .query (
284+ db .func .count (Solves .id )
285+ )
286+ .filter (Solves .challenge_id == challenge .id )
287+ .join (Model , Solves .account_id == Model .id )
288+ .filter (Model .banned == False , Model .hidden == False )
289+ .scalar ()
290+ )
291+
292+ max_solves = get_config ('notifier_solve_count' )
293+ max_solves = int (max_solves ) if max_solves is not None else None
294+
295+ if max_solves is None or solve_count <= max_solves :
296+ notifier .notify_solve (get_config ('notifier_solve_msg' , '{solver} solved {challenge} ({solve_num} solve)' ), solver .name , solver_url , challenge .name , challenge_url , solve_count )
297+ return wrapper
298+ BaseChallenge .solve = chal_solve_decorator (BaseChallenge .solve )
299+
300+ def event_publish_decorator (event_publish_func ):
301+ @wraps (event_publish_func )
302+ def wrapper (* args , ** kwargs ):
303+ event_publish_func (args , kwargs )
304+
305+ if kwargs ['type' ] == 'notification' :
306+ notifier = get_configured_notifier ()
307+ if notifier and bool (get_config ('notifier_send_notifications' )):
308+ notification = kwargs ['data' ]
309+ notifier .notify_message (notification ['title' ], notification ['content' ])
310+ return wrapper
311+ app .events_manager .publish = event_publish_decorator (app .events_manager .publish )
0 commit comments