From e7bc320a170c3a902d3bb6d0d072f181208e4e4b Mon Sep 17 00:00:00 2001 From: Xusheng Date: Sat, 26 Sep 2026 10:02:00 -0400 Subject: [PATCH 1/2] Announce first blood instead of flag activity --- app/controllers/crackme.py | 27 ++++----------- app/models/solve.py | 17 ++++++++++ app/services/discord.py | 46 +++----------------------- tests/test_services_and_failures.py | 16 ++------- tests/test_solves.py | 51 ++++++++++++++++++----------- 5 files changed, 61 insertions(+), 96 deletions(-) diff --git a/app/controllers/crackme.py b/app/controllers/crackme.py index f87b4d4..2bcba04 100644 --- a/app/controllers/crackme.py +++ b/app/controllers/crackme.py @@ -22,6 +22,7 @@ ) from app.models.solve import ( solve_by_user_and_crackme, solve_create, count_solves_by_crackme, + claim_first_blood_notification, solves_by_crackme ) from app.models.flag_submission import ( @@ -38,7 +39,7 @@ is_unsupported_archive, is_zip_archive, ) from app.services.discord import ( - notify_flag_solved, notify_flag_submission, notify_new_crackme + notify_first_blood, notify_new_crackme ) from app.services.flag import ( FLAG_FORMAT_HINT, flags_match, is_valid_flag_format, normalize_flag @@ -489,13 +490,6 @@ def log_result(result, user_hexid=None): except Exception as e: # Audit logging must not turn a validation response into a 500. print(f"Error logging flag submission: {e}") - try: - notify_flag_submission( - username, crackme.get('name', ''), hexid, flag, result - ) - except Exception as e: - # Discord must never affect validation or its database audit trail. - print(f"Discord flag-audit notification error: {e}") try: crackme = crackme_by_hexid(hexid) @@ -544,7 +538,7 @@ def log_result(result, user_hexid=None): points = points_for_solve(crackme) try: - solve_create(user_hexid, hexid, points, solve_difficulty(crackme)) + solve = solve_create(user_hexid, hexid, points, solve_difficulty(crackme)) except Exception as e: print(f"Error recording solve: {e}") abort(500) @@ -552,19 +546,10 @@ def log_result(result, user_hexid=None): log_result('correct', user_hexid) try: - notify_flag_solved( - username, crackme.get('name', ''), hexid, points - ) + if claim_first_blood_notification(hexid, solve['hexid']): + notify_first_blood(username, crackme.get('name', ''), hexid, points) except Exception as e: - print(f"Discord solve notification error: {e}") - - try: - notification_add( - username, - f"Correct flag for '{html_escape(crackme.get('name', ''))}' - {points} points earned!" - ) - except Exception as e: - print(f"Notification error: {e}") + print(f"Discord first-blood notification error: {e}") flash(f'Correct! You earned {points} points.', FLASH_SUCCESS) return redirect(f'/crackme/{hexid}') diff --git a/app/models/solve.py b/app/models/solve.py index c0bb42f..bec08dc 100644 --- a/app/models/solve.py +++ b/app/models/solve.py @@ -189,6 +189,23 @@ def count_solves_by_crackme(crackme_hexid): ) +def claim_first_blood_notification(crackme_hexid, solve_hexid): + """Claim the announcement once, and only for the earliest recorded solve.""" + if not check_connection(): + raise ErrUnavailable("Database is unavailable") + + first = get_collection('solve').find_one( + {'crackme_hexid': crackme_hexid}, sort=[('_id', 1)] + ) + if not first or first['hexid'] != solve_hexid: + return False + + return get_collection('crackme').find_one_and_update( + {'hexid': crackme_hexid, 'first_blood_notified': {'$ne': True}}, + {'$set': {'first_blood_notified': True}}, + ) is not None + + def solves_by_crackme(crackme_hexid): """Return a crackme's solves, oldest first.""" if not check_connection(): diff --git a/app/services/discord.py b/app/services/discord.py index f6cd15f..145538c 100644 --- a/app/services/discord.py +++ b/app/services/discord.py @@ -192,9 +192,9 @@ def notify_new_solution(username: str, crackme_name: str) -> bool: return send_private_notification(embed=embed) -def notify_flag_solved(username: str, crackme_name: str, +def notify_first_blood(username: str, crackme_name: str, crackme_hexid: str, points: int) -> bool: - """Announce a successful auto-validated solve in the public channel.""" + """Announce the first successful solve in the public channel.""" timestamp = ( datetime.datetime.utcnow() .replace(tzinfo=timezone.utc) @@ -203,8 +203,8 @@ def notify_flag_solved(username: str, crackme_name: str, ) base_url = get_base_url() embed = { - "title": "Crackme Solved", - "description": "A player submitted the correct flag", + "title": "First Blood", + "description": "The first player solved this challenge", "color": 65280, "fields": [ { @@ -227,44 +227,6 @@ def notify_flag_solved(username: str, crackme_name: str, return send_to_webhook(get_public_webhook(), embed=embed) -def notify_flag_submission(username: str, crackme_name: str, - crackme_hexid: str, submitted_flag: str, - result: str) -> bool: - """Send every flag attempt, including its value, to the audit channel.""" - timestamp = ( - datetime.datetime.utcnow() - .replace(tzinfo=timezone.utc) - .isoformat(timespec='milliseconds') - .replace('+00:00', 'Z') - ) - base_url = get_base_url() - # Discord limits an embed field to 1,024 characters. The database remains - # the canonical, untruncated audit record. - displayed_flag = submitted_flag[:1021] + "..." if len(submitted_flag) > 1024 else submitted_flag - embed = { - "title": "Flag Submitted", - "description": "Auto-validation attempt recorded", - "color": 65280 if result == 'correct' else 16744448, - "fields": [ - { - "name": "Challenge", - "value": f"[{crackme_name}]({base_url}/crackme/{crackme_hexid})", - "inline": True, - }, - { - "name": "User", - "value": f"[{username}]({base_url}/user/{username})", - "inline": True, - }, - {"name": "Result", "value": result, "inline": True}, - {"name": "Submitted flag", "value": displayed_flag or "(empty)", "inline": False}, - ], - "footer": {"text": "CrackMes.One Internal Audit"}, - "timestamp": timestamp, - } - return send_private_notification(embed=embed) - - def send_moderation_notification(embed: dict) -> bool: """Send a notification to the moderation Discord channel. diff --git a/tests/test_services_and_failures.py b/tests/test_services_and_failures.py index f08e55c..9392893 100644 --- a/tests/test_services_and_failures.py +++ b/tests/test_services_and_failures.py @@ -62,7 +62,7 @@ def test_pending_discord_notifications_use_private_channel(app): discord.init_discord(app, {'Enabled': False}) -def test_flag_discord_notifications_use_public_and_audit_channels(app): +def test_first_blood_discord_notification_uses_public_channel(app): from app.services import discord discord.init_discord(app, { @@ -71,23 +71,13 @@ def test_flag_discord_notifications_use_public_and_audit_channels(app): 'WebhookPrivate': 'https://discord.test/private', }) with patch.object(discord, 'send_to_webhook', return_value=True) as send: - assert discord.notify_flag_solved('bob', 'Challenge', 'abc', 300) is True + assert discord.notify_first_blood('bob', 'Challenge', 'abc', 300) is True assert send.call_args.args[0].endswith('/public') public_embed = send.call_args.kwargs['embed'] - assert public_embed['title'] == 'Crackme Solved' + assert public_embed['title'] == 'First Blood' assert public_embed['fields'][0]['value'] == '[Challenge](http://localhost/crackme/abc)' assert public_embed['fields'][1]['value'] == '[bob](http://localhost/user/bob)' - with patch.object(discord, 'send_private_notification', return_value=True) as send: - assert discord.notify_flag_submission( - 'bob', 'Challenge', 'abc', 'CMO{attempt}', 'incorrect' - ) is True - audit_embed = send.call_args.kwargs['embed'] - assert audit_embed['title'] == 'Flag Submitted' - assert audit_embed['fields'][0]['value'] == '[Challenge](http://localhost/crackme/abc)' - assert audit_embed['fields'][1]['value'] == '[bob](http://localhost/user/bob)' - assert audit_embed['fields'][2]['value'] == 'incorrect' - assert audit_embed['fields'][3]['value'] == 'CMO{attempt}' discord.init_discord(app, {'Enabled': False}) diff --git a/tests/test_solves.py b/tests/test_solves.py index 60faeae..f8632cb 100644 --- a/tests/test_solves.py +++ b/tests/test_solves.py @@ -204,14 +204,9 @@ def test_auto_validation_points_default_to_selected_difficulty( def test_correct_flag_records_a_solve_and_awards_points( bob_client, db, bob, flagged_crackme, monkeypatch): from app.controllers import crackme as crackme_controller - audit_notifications = [] solve_notifications = [] monkeypatch.setattr( - crackme_controller, 'notify_flag_submission', - lambda *args: audit_notifications.append(args), - ) - monkeypatch.setattr( - crackme_controller, 'notify_flag_solved', + crackme_controller, 'notify_first_blood', lambda *args: solve_notifications.append(args), ) response = bob_client.post(f"/crackme/{flagged_crackme['hexid']}/solve", @@ -223,21 +218,46 @@ def test_correct_flag_records_a_solve_and_awards_points( assert solve['crackme_hexid'] == flagged_crackme['hexid'] assert solve['points'] == 300 assert solve['difficulty'] == 3 - assert db.notifications.count_documents({'user': 'bob'}) == 1 + assert db.notifications.count_documents({'user': 'bob'}) == 0 submission = db.flag_submission.find_one({}) assert submission['user_hexid'] == _hexid(bob) assert submission['username'] == 'bob' assert submission['crackme_hexid'] == flagged_crackme['hexid'] assert submission['submitted_flag'] == FLAG assert submission['result'] == 'correct' - assert audit_notifications == [( - 'bob', flagged_crackme['name'], flagged_crackme['hexid'], FLAG, 'correct' - )] assert solve_notifications == [( 'bob', flagged_crackme['name'], flagged_crackme['hexid'], 300 )] +def test_only_first_solver_gets_public_announcement( + app, bob_client, db, bob, flagged_crackme, monkeypatch): + from app.controllers import crackme as crackme_controller + + announcements = [] + monkeypatch.setattr( + crackme_controller, 'notify_first_blood', + lambda *args: announcements.append(args), + ) + path = f"/crackme/{flagged_crackme['hexid']}/solve" + bob_client.post(path, data={'flag': FLAG}) + + db.user.insert_one({'name': 'charlie', 'email': 'charlie@example.test'}) + charlie_client = app.test_client() + with charlie_client.session_transaction() as session: + session['name'] = 'charlie' + session['email'] = 'charlie@example.test' + charlie_client.post(path, data={'flag': FLAG}) + bob_client.post(path, data={'flag': FLAG}) + + assert db.solve.count_documents({'crackme_hexid': flagged_crackme['hexid']}) == 2 + assert len(announcements) == 1 + assert announcements[0][0] == 'bob' + assert db.crackme.find_one({'hexid': flagged_crackme['hexid']})[ + 'first_blood_notified' + ] is True + + def test_solve_snapshots_fractional_difficulty( bob_client, db, bob, flagged_crackme): db.crackme.update_one( @@ -257,14 +277,9 @@ def test_solve_snapshots_fractional_difficulty( def test_wrong_flag_records_nothing( bob_client, db, bob, flagged_crackme, monkeypatch): from app.controllers import crackme as crackme_controller - audit_notifications = [] solve_notifications = [] monkeypatch.setattr( - crackme_controller, 'notify_flag_submission', - lambda *args: audit_notifications.append(args), - ) - monkeypatch.setattr( - crackme_controller, 'notify_flag_solved', + crackme_controller, 'notify_first_blood', lambda *args: solve_notifications.append(args), ) response = bob_client.post(f"/crackme/{flagged_crackme['hexid']}/solve", @@ -276,10 +291,6 @@ def test_wrong_flag_records_nothing( submission = db.flag_submission.find_one({}) assert submission['submitted_flag'] == 'CMO{nope}' assert submission['result'] == 'incorrect' - assert audit_notifications == [( - 'bob', flagged_crackme['name'], flagged_crackme['hexid'], - 'CMO{nope}', 'incorrect' - )] assert solve_notifications == [] From eaead36c728e6ee4e35955c5454b98a17f8b7105 Mon Sep 17 00:00:00 2001 From: Xusheng Date: Sat, 26 Sep 2026 10:04:49 -0400 Subject: [PATCH 2/2] Notify challenge authors when first blood is recorded --- app/controllers/crackme.py | 21 ++++++++++++++++++--- tests/test_solves.py | 5 +++++ 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/app/controllers/crackme.py b/app/controllers/crackme.py index 2bcba04..ff7e7f2 100644 --- a/app/controllers/crackme.py +++ b/app/controllers/crackme.py @@ -546,10 +546,25 @@ def log_result(result, user_hexid=None): log_result('correct', user_hexid) try: - if claim_first_blood_notification(hexid, solve['hexid']): - notify_first_blood(username, crackme.get('name', ''), hexid, points) + first_blood = claim_first_blood_notification(hexid, solve['hexid']) except Exception as e: - print(f"Discord first-blood notification error: {e}") + print(f"First-blood claim error: {e}") + first_blood = False + + if first_blood: + try: + notification_add( + crackme['author'], + f"Your crackme '{html_escape(crackme.get('name', ''))}' " + f"was solved for the first time by {html_escape(username)}!" + ) + except Exception as e: + print(f"First-blood author notification error: {e}") + + try: + notify_first_blood(username, crackme.get('name', ''), hexid, points) + except Exception as e: + print(f"Discord first-blood notification error: {e}") flash(f'Correct! You earned {points} points.', FLASH_SUCCESS) return redirect(f'/crackme/{hexid}') diff --git a/tests/test_solves.py b/tests/test_solves.py index f8632cb..4b28321 100644 --- a/tests/test_solves.py +++ b/tests/test_solves.py @@ -219,6 +219,9 @@ def test_correct_flag_records_a_solve_and_awards_points( assert solve['points'] == 300 assert solve['difficulty'] == 3 assert db.notifications.count_documents({'user': 'bob'}) == 0 + author_notification = db.notifications.find_one({'user': 'alice'}) + assert author_notification['seen'] is False + assert 'was solved for the first time by bob' in author_notification['text'] submission = db.flag_submission.find_one({}) assert submission['user_hexid'] == _hexid(bob) assert submission['username'] == 'bob' @@ -253,6 +256,8 @@ def test_only_first_solver_gets_public_announcement( assert db.solve.count_documents({'crackme_hexid': flagged_crackme['hexid']}) == 2 assert len(announcements) == 1 assert announcements[0][0] == 'bob' + assert db.notifications.count_documents({'user': 'alice'}) == 1 + assert db.user.find_one({'name': 'alice'})['unread_notifications'] == 1 assert db.crackme.find_one({'hexid': flagged_crackme['hexid']})[ 'first_blood_notified' ] is True