Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 74 additions & 4 deletions app/controllers/crackme.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,15 @@
from app.models.flag_submission import (
flag_submission_create, count_flag_submissions_by_crackme
)
from app.models.user import user_by_name, users_by_hexids
from app.models.user import (
user_by_name, users_by_hexids,
user_all_solved_hexids, # Issue #182
user_toggle_manual_solve, # Issue #182
)
from app.models.errors import ErrNoResult
from app.services.recaptcha import verify as verify_recaptcha
from app.services.limiter import configured_limit, limit
from app.services.view import FLASH_ERROR, FLASH_SUCCESS, FLASH_NOTICE, validate_required
from app.services.view import FLASH_ERROR, FLASH_SUCCESS, FLASH_NOTICE, validate_required, is_valid_hexid
from app.services.labels import get_label_groups, get_dataset_url, normalize_labels
from app.services.archive import (
is_archive_password_protected, is_single_file_archive,
Expand Down Expand Up @@ -107,6 +111,17 @@ def crackme_view(hexid):
except Exception as e:
print(f"Error getting solve data: {e}")

# Issue #182: Check solved state for manual and official solve tracking
is_officially_solved = False
is_manually_solved = False
if usersess:
try:
solved_sets = user_all_solved_hexids(usersess)
is_officially_solved = hexid in solved_sets['official']
is_manually_solved = hexid in solved_sets['manual']
except Exception as e:
print(f"Error checking solved state for {hexid}: {e}")

# Build mention targets for @mention autocomplete (author + commenters + solution authors)
mention_targets = {crackme.get('author', '')}
for comment in comments:
Expand Down Expand Up @@ -144,7 +159,10 @@ def crackme_view(hexid):
user_solve=user_solve,
solve_points=points_for_solve(crackme) if auto_validation else 0,
flag_format_hint=FLAG_FORMAT_HINT,
usersess=usersess)
usersess=usersess,
is_officially_solved=is_officially_solved,
is_manually_solved=is_manually_solved,
is_solved=(is_officially_solved or is_manually_solved))


def _user_hexid(username):
Expand Down Expand Up @@ -178,10 +196,20 @@ def last_crackmes_page(page):
print(f"Error getting crackmes: {e}")
abort(500)

# Issue #182: pass solved sets so the template can grey-out done crackmes
username = session.get('name')
solved_sets = user_all_solved_hexids(username) if username else {
'official': frozenset(), 'manual': frozenset(), 'all': frozenset()
}

return render_template('crackme/lasts.html',
crackmes=crackmes,
page=page,
has_more=has_more)
has_more=has_more,
official_solves=solved_sets['official'],
manual_solves=solved_sets['manual'],
all_solves=solved_sets['all'],
is_logged_in=bool(username))


@crackme_bp.route('/download/crackme/<hexid>')
Expand Down Expand Up @@ -567,6 +595,48 @@ def log_result(result, user_hexid=None):
return redirect(f'/crackme/{hexid}')


@crackme_bp.route('/crackme/<hexid>/toggle-manual-solve', methods=['POST'])
@login_required
@limit(configured_limit('ManualSolveToggle'), key_func=lambda: session.get('name'))
def toggle_manual_solve(hexid):
"""Toggle a manual 'done' mark for the authenticated user.

Manual marks are personal progress tracking only. They have zero impact
on points, ratings, or the public scoreboard.

Returns JSON: {"ok": true, "solved": bool}
or {"ok": false, "error": str} with appropriate HTTP status.
"""
username = session.get('name')

# Validate the hexid from the URL using the existing project utility
if not is_valid_hexid(hexid):
return jsonify({'ok': False, 'error': 'Invalid crackme ID'}), 400

# Guard: official solves cannot be manually unmarked (server-side enforcement)
user_hexid = _user_hexid(username)
if user_hexid:
try:
if solve_by_user_and_crackme(user_hexid, hexid):
return jsonify({
'ok': False,
'error': 'Official flag solves cannot be manually unmarked',
'locked': True,
}), 403
except Exception:
pass # If we can't check, proceed -- the UI enforces this too

try:
is_solved = user_toggle_manual_solve(username, hexid)
return jsonify({'ok': True, 'solved': is_solved})
except ValueError as e:
# Covers: invalid hexid, crackme not found/visible, cap exceeded
return jsonify({'ok': False, 'error': str(e)}), 400
except Exception as e:
print(f"Error toggling manual solve for {username}/{hexid}: {e}")
return jsonify({'ok': False, 'error': 'Server error'}), 500


@crackme_bp.route('/crackme/<hexid>/edit', methods=['GET'])
@login_required
def edit_crackme_get(hexid):
Expand Down
84 changes: 74 additions & 10 deletions app/controllers/search.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@
Search controller - Searching crackmes.
"""

from flask import Blueprint, render_template, request
from flask import Blueprint, render_template, request, session
from app.models.crackme import search_crackme, random_crackmes
from app.models.user import user_all_solved_hexids # Issue #182
from app.models.errors import ErrUnavailable
from app.services.labels import get_label_groups, normalize_labels

Expand All @@ -18,9 +19,23 @@ def search_get():
shareable (issue #162). A bare ``/search`` with no parameters shows the empty
form; any parameter (e.g. ``/search?labels=Packer``) runs a search.
"""
username = session.get('name')
if not request.args:
return render_template('search/search.html', crackmes=[], page=1, has_more=False,
show_all=False, search_params={}, label_groups=get_label_groups())
solved_sets = user_all_solved_hexids(username) if username else {
'official': frozenset(), 'manual': frozenset(), 'all': frozenset()
}
return render_template('search/search.html',
crackmes=[],
page=1,
has_more=False,
show_all=False,
search_params={},
label_groups=get_label_groups(),
official_solves=solved_sets['official'],
manual_solves=solved_sets['manual'],
all_solves=solved_sets['all'],
hide_solved=False,
is_logged_in=bool(username))
return _render_search(request.args)


Expand Down Expand Up @@ -174,6 +189,22 @@ def parse_size_with_unit(value_str, unit_str):
if sort_order not in ('asc', 'desc'):
sort_order = 'desc'

# --- Solved crackmes (Issue #182) ---
# For logged-in users: build a unified set of solved hexids (official +
# manual) in a single function call (2 DB reads total, not N+1).
# For guests: empty sets -- the feature is invisible but the page still works.
username = session.get('name')
solved_sets = user_all_solved_hexids(username) if username else {
'official': frozenset(), 'manual': frozenset(), 'all': frozenset()
}
official_solves = solved_sets['official']
manual_solves = solved_sets['manual']
all_solves = solved_sets['all']

# Hide solved: only active for logged-in users
hide_solved = bool(source.get('hide_solved')) and bool(username)
exclude_hexids = list(all_solves) if hide_solved else None

# Store search params for pagination
# lang, arch, platform are lists for multi-select support
search_params = {
Expand All @@ -198,7 +229,8 @@ def parse_size_with_unit(value_str, unit_str):
'size-max': size_max_str,
'size-max-unit': size_max_unit,
'sort_by': sort_by,
'sort_order': sort_order
'sort_order': sort_order,
'hide_solved': hide_solved, # Issue #182
}

try:
Expand All @@ -225,7 +257,8 @@ def parse_size_with_unit(value_str, unit_str):
sort_by=sort_by,
sort_order=sort_order,
page=1,
per_page=10000
per_page=10000,
exclude_hexids=exclude_hexids # Issue #182
)
has_more = False
else:
Expand All @@ -250,7 +283,8 @@ def parse_size_with_unit(value_str, unit_str):
size_max=size_max,
sort_by=sort_by,
sort_order=sort_order,
page=page
page=page,
exclude_hexids=exclude_hexids # Issue #182
)
except Exception as e:
print(f"Search error: {e}")
Expand All @@ -263,7 +297,12 @@ def parse_size_with_unit(value_str, unit_str):
has_more=has_more,
show_all=show_all,
search_params=search_params,
label_groups=get_label_groups())
label_groups=get_label_groups(),
official_solves=official_solves, # Issue #182
manual_solves=manual_solves, # Issue #182
all_solves=all_solves, # Issue #182
hide_solved=hide_solved, # Issue #182
is_logged_in=bool(username)) # Issue #182


@search_bp.route('/random', methods=['GET'])
Expand All @@ -277,8 +316,24 @@ def random_get():
if sort_order not in ('asc', 'desc'):
sort_order = 'desc'

username = session.get('name')
hide_solved = bool(request.args.get('hide_solved'))

if username:
solved_sets = user_all_solved_hexids(username)
official_solves = solved_sets['official']
manual_solves = solved_sets['manual']
all_solves = solved_sets['all']
exclude_hexids = all_solves if (hide_solved and all_solves) else None
else:
official_solves = frozenset()
manual_solves = frozenset()
all_solves = frozenset()
exclude_hexids = None
hide_solved = False

try:
crackmes = random_crackmes(50)
crackmes = random_crackmes(50, exclude_hexids=exclude_hexids)
# Sort the random results
sort_key_map = {
'date': 'created_at',
Expand All @@ -294,12 +349,21 @@ def random_get():
except ErrUnavailable:
crackmes = []

search_params = {'sort_by': sort_by, 'sort_order': sort_order}
search_params = {
'sort_by': sort_by,
'sort_order': sort_order,
'hide_solved': '1' if hide_solved else '',
}

return render_template('search/search.html',
crackmes=crackmes,
page=1,
has_more=False,
show_all=True,
search_params=search_params,
label_groups=get_label_groups())
label_groups=get_label_groups(),
official_solves=official_solves,
manual_solves=manual_solves,
all_solves=all_solves,
hide_solved=hide_solved,
is_logged_in=bool(username))
20 changes: 17 additions & 3 deletions app/models/crackme.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,8 @@ def search_crackme(name='', author='', lang='', arch='', platform='',
comments_min=0, comments_max=None,
size_min=0, size_max=None,
sort_by='date', sort_order='desc',
page=1, per_page=50):
page=1, per_page=50,
exclude_hexids=None): # Issue #182: hide solved crackmes
"""Search crackmes with filters, sorting, and pagination.

Returns (results, has_more) where has_more indicates if there are more pages.
Expand Down Expand Up @@ -198,6 +199,13 @@ def search_crackme(name='', author='', lang='', arch='', platform='',
if labels:
query['labels'] = {'$all': labels}

# Hide solved crackmes: exclude a caller-supplied set of hexids.
# This runs BEFORE .skip()/.limit() so page counts remain accurate.
if exclude_hexids:
exclude_list = list(set(exclude_hexids))
if exclude_list:
query['hexid'] = {'$nin': exclude_list}

skip = (page - 1) * per_page
# Determine sort field and direction
sort_field = SORT_FIELDS.get(sort_by, 'created_at')
Expand Down Expand Up @@ -375,14 +383,20 @@ def crackme_delete_by_hexid(hexid):
collection.delete_one({'hexid': hexid})


def random_crackmes(count=50):
def random_crackmes(count=50, exclude_hexids=None):
"""Get multiple random visible crackmes."""
if not check_connection():
raise ErrUnavailable("Database is unavailable")

collection = get_collection('crackme')
match = {'visible': True}
if exclude_hexids:
exclude_list = list(set(exclude_hexids))
if exclude_list:
match['hexid'] = {'$nin': exclude_list}

pipeline = [
{'$match': {'visible': True}},
{'$match': match},
{'$sample': {'size': count}}
]
return list(collection.aggregate(pipeline))
Expand Down
Loading