diff --git a/app/controllers/crackme.py b/app/controllers/crackme.py
index f87b4d4..ff7e7f2 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,25 @@ def log_result(result, user_hexid=None):
log_result('correct', user_hexid)
try:
- notify_flag_solved(
- username, crackme.get('name', ''), hexid, points
- )
+ first_blood = claim_first_blood_notification(hexid, solve['hexid'])
except Exception as e:
- print(f"Discord solve notification error: {e}")
+ print(f"First-blood claim error: {e}")
+ first_blood = False
- 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}")
+ 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/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..4b28321 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,51 @@ 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
+ 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'
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.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
+
+
def test_solve_snapshots_fractional_difficulty(
bob_client, db, bob, flagged_crackme):
db.crackme.update_one(
@@ -257,14 +282,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 +296,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 == []