From e13f42dabc99b73d4288f8a964e3aa32a5a83e3c Mon Sep 17 00:00:00 2001 From: 0M4NU4L Date: Tue, 10 Nov 2026 04:29:00 +0530 Subject: [PATCH] Allow users to mark crackmes as done (#182) Add manual "mark as done" solve tracking for personal progress tracking (#182). - Add manual_solves array to user document with atomic $addToSet / $pull updates, 5000-solve cap, and O(1) query-level projection. - Add POST /crackme//toggle-manual-solve with 30/minute rate limiter and server-side guard blocking manual unmarking of official flag solves. - Update challenge detail page (/crackme/) with "Mark as done" / "Done" action button styled identically to [Download] (#9acc14 solid green, white text, flat square borders) with tooltip "Mark as done. Only visible to yourself" / "Mark it as undone. Only visible to yourself". - Support instant client-side toggle, row dimming, and client-side sink-to-bottom reordering on search and latest crackmes lists. - Add "Hide solved crackmes" filter option on /search and preserve parameter across pagination and random cracky links. - Graceful guest fallback via browser localStorage when logged out. - Add comprehensive test suite in tests/test_manual_solves.py covering models, controllers, filters, rate limits, and official solve integration. --- app/controllers/crackme.py | 78 +++++- app/controllers/search.py | 84 +++++- app/models/crackme.py | 20 +- app/models/user.py | 118 ++++++++ app/services/limiter.py | 1 + static/css/custom.css | 73 +++++ static/js/mark_solved.js | 330 ++++++++++++++++++++++ templates/crackme/lasts.html | 29 +- templates/crackme/read.html | 41 ++- templates/search/search.html | 51 +++- tests/test_manual_solves.py | 519 +++++++++++++++++++++++++++++++++++ 11 files changed, 1314 insertions(+), 30 deletions(-) create mode 100644 static/js/mark_solved.js create mode 100644 tests/test_manual_solves.py diff --git a/app/controllers/crackme.py b/app/controllers/crackme.py index 07465b3..5b8c7be 100644 --- a/app/controllers/crackme.py +++ b/app/controllers/crackme.py @@ -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, @@ -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: @@ -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): @@ -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/') @@ -567,6 +595,48 @@ def log_result(result, user_hexid=None): return redirect(f'/crackme/{hexid}') +@crackme_bp.route('/crackme//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//edit', methods=['GET']) @login_required def edit_crackme_get(hexid): diff --git a/app/controllers/search.py b/app/controllers/search.py index dedbc98..b24b6d9 100644 --- a/app/controllers/search.py +++ b/app/controllers/search.py @@ -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 @@ -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) @@ -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 = { @@ -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: @@ -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: @@ -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}") @@ -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']) @@ -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', @@ -294,7 +349,11 @@ 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, @@ -302,4 +361,9 @@ def random_get(): 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)) diff --git a/app/models/crackme.py b/app/models/crackme.py index e1b6a5e..a1658ea 100644 --- a/app/models/crackme.py +++ b/app/models/crackme.py @@ -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. @@ -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') @@ -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)) diff --git a/app/models/user.py b/app/models/user.py index c502e80..e852aa2 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -246,3 +246,121 @@ def user_decrement_unread_notifications(username, count=1): {'name': username}, {'$set': {'unread_notifications': new_count}} ) + + +# -- Manual solve tracking (Issue #182) ------------------------------------ + +_HEXID_RE = re.compile(r'^[a-f0-9]{24}$') +_MANUAL_SOLVE_HARD_CAP = 5_000 + + +def user_toggle_manual_solve(username: str, crackme_hexid: str) -> bool: + """Atomically toggle a manual solve mark for a user. + + Manual solves are strictly for personal progress tracking. They do NOT + touch points, ratings, or any scoreboard field -- only the manual_solves + array on the user document. + + Args: + username: The session username (session['name']). + crackme_hexid: 24-char hex ID of the crackme to toggle. + + Returns: + True if crackme is now manually marked as done. + False if crackme has been unmarked. + + Raises: + ValueError: Invalid hex ID format, or crackme doesn't exist / + isn't visible. + ErrUnavailable: Database is not reachable. + """ + if not check_connection(): + raise ErrUnavailable("Database is unavailable") + + # --- Input validation --- + if not username or not isinstance(username, str): + raise ValueError("username must be a non-empty string") + + if not _HEXID_RE.match(crackme_hexid or ''): + raise ValueError("crackme_hexid must be a 24-character hex string") + + # --- Crackme existence check (1 query, limit=1 is O(1) on the index) --- + crackme_exists = get_collection('crackme').count_documents( + {'hexid': crackme_hexid, 'visible': True}, limit=1 + ) + if not crackme_exists: + raise ValueError(f"Crackme '{crackme_hexid}' not found or not visible") + + user_col = get_collection('user') + + # --- Determine current state: projection fetches only what we need --- + user = user_col.find_one({'name': username}, {'manual_solves': 1}) + if user is None: + raise ValueError(f"User '{username}' not found") + + current_solves = set(user.get('manual_solves') or []) + already_solved = crackme_hexid in current_solves + + if already_solved: + # Unmark: atomic pull + user_col.update_one( + {'name': username}, + {'$pull': {'manual_solves': crackme_hexid}} + ) + return False + else: + # Guard: prevent document bloat beyond hard cap + if len(current_solves) >= _MANUAL_SOLVE_HARD_CAP: + raise ValueError( + f"Manual solve limit of {_MANUAL_SOLVE_HARD_CAP} reached" + ) + # Mark: atomic addToSet prevents duplicates under any race condition + user_col.update_one( + {'name': username}, + {'$addToSet': {'manual_solves': crackme_hexid}} + ) + return True + + +def user_all_solved_hexids(username: str) -> dict: + """Return all crackme hexids a user has completed, by source. + + Makes exactly TWO database reads: + 1. User document projection -> manual_solves list + 2. Solve collection filter -> official flag-validated solves + + Returns a dict:: + + { + "official": frozenset of hexids (flag-submitted, immutable), + "manual": frozenset of hexids (manually toggled), + "all": frozenset union (use this for grey-out + hide logic), + } + + If the user doesn't exist or the database is unreachable, returns empty + sets -- the caller must handle the graceful degradation itself. + """ + if not check_connection(): + return {"official": frozenset(), "manual": frozenset(), "all": frozenset()} + + # 1. Manual solves -- single projection read on the user document + user = get_collection('user').find_one( + {'name': username}, {'hexid': 1, 'manual_solves': 1} + ) + if not user: + return {"official": frozenset(), "manual": frozenset(), "all": frozenset()} + + manual = frozenset(user.get('manual_solves') or []) + + # 2. Official flag solves -- keyed by user hexid (immutable, rename-safe) + user_hexid = user.get('hexid') or str(user['_id']) + solve_docs = get_collection('solve').find( + {'user_hexid': user_hexid}, {'crackme_hexid': 1} + ) + official = frozenset(doc['crackme_hexid'] for doc in solve_docs) + + return { + "official": official, + "manual": manual, + "all": official | manual, + } diff --git a/app/services/limiter.py b/app/services/limiter.py index d7b536a..450c128 100644 --- a/app/services/limiter.py +++ b/app/services/limiter.py @@ -43,6 +43,7 @@ DEFAULT_LIMITS = { 'FlagSubmissions': '5 per minute; 20 per hour', + 'ManualSolveToggle': '30 per minute', # Issue #182: manual solve toggle } diff --git a/static/css/custom.css b/static/css/custom.css index 5545fbb..7b178b7 100644 --- a/static/css/custom.css +++ b/static/css/custom.css @@ -528,3 +528,76 @@ h3:hover .anchor-link { margin-top: 10px; margin-bottom: 8px; } + +/* -- Issue #182: Manual Mark as Done ---------------------------------------- + Affects search/lasts tables only. No impact on scoreboard, profile, or + any other page. Official solves (flag-submitted) share the same grey-out + but show a locked indicator instead of a toggle button. */ + +/* Solved row: greyed out but still readable */ +tr.crackme-row-solved { + opacity: 0.45; + filter: grayscale(65%); + /* NOTE: CSS `order` has no effect on inside . + Row reordering is done via DOM appendChild() in mark_solved.js */ +} + +/* The toggle button: minimal, matches site accent colour */ +.btn-mark-solved { + padding: 0 5px; + font-size: 1rem; + line-height: 1; + border: none; + background: transparent; + cursor: pointer; + color: #9acc14; + transition: transform 0.1s ease; + vertical-align: middle; +} +.btn-mark-solved:hover { transform: scale(1.25); color: #b8f016; } +.btn-mark-solved:focus { outline: 1px dotted #9acc14; } + +/* Challenge detail page action buttons - identical to .btn-download */ +.btn-page-action { + border-radius: 0% !important; + float: right; + height: 2rem !important; + font-size: 0.9rem !important; + padding: 0.45rem 0.6rem !important; + line-height: 1rem !important; + box-sizing: border-box !important; + transition: all 0.15s ease-in-out !important; + white-space: nowrap !important; + vertical-align: middle !important; + cursor: pointer !important; + background: #9acc14 !important; + border: 1px solid #9acc14 !important; + color: #ffffff !important; +} +.btn-page-action:hover { + background: #86b310 !important; + border-color: #86b310 !important; + color: #ffffff !important; +} + +/* Locked official solve badge on detail page */ +.mark-solved-locked-badge { + border-radius: 0% !important; + float: right; + cursor: default; + background: #9acc14 !important; + border: 1px solid #9acc14 !important; + color: #ffffff !important; +} + +/* Locked official solve: same grey + lock icon, not clickable */ +.mark-solved-locked { + color: #6b6b6b; + font-size: 0.85rem; + cursor: not-allowed; + vertical-align: middle; +} + +/* When a row is solved, dim the toggle button further */ +tr.crackme-row-solved .btn-mark-solved { color: #6b6b6b; } +/* -- End Issue #182 ------------------------------------------------------- */ diff --git a/static/js/mark_solved.js b/static/js/mark_solved.js new file mode 100644 index 0000000..253c53d --- /dev/null +++ b/static/js/mark_solved.js @@ -0,0 +1,330 @@ +/** + * mark_solved.js -- Issue #182: Manual "Mark as Done" + * + * Responsibilities: + * 1. On DOMContentLoaded: reorder so .crackme-row-solved rows + * sink to the bottom (CSS `order` doesn't work on ). + * 2. Handle toggle button clicks via event delegation (single listener). + * 3. POST to /crackme//toggle-manual-solve with CSRF. + * 4. Optimistic UI update: change state immediately, roll back on error. + * 5. Guest fallback: if not logged in, use localStorage only. + * + * NOTE (pagination): Row reordering is per-page (client-side DOM only). + * A user may see solved rows at the bottom of page 1 and unsolved rows at + * the top of page 2. This is expected for a purely client-side sort. + * Server-side hide_solved=1 fully excludes solved rows at query level. + * + * No external dependencies. No jQuery. No ES modules. + */ +(function () { + 'use strict'; + + // -- Constants --------------------------------------------------------------- + var SOLVED_CLASS = 'crackme-row-solved'; + var GLYPH_UNSOLVED = '\u2610'; // ☐ + var GLYPH_SOLVED = '\u2611'; // ☑ + var LS_KEY = 'crackmesone_manual_solved'; + + // -- localStorage fallback (guests / localStorage blocked) ------------------ + function lsLoad() { + try { + var raw = localStorage.getItem(LS_KEY); + return raw ? new Set(JSON.parse(raw)) : new Set(); + } catch (e) { return new Set(); } + } + + function lsSave(set) { + try { localStorage.setItem(LS_KEY, JSON.stringify(Array.from(set))); } + catch (e) { /* quota exceeded or blocked -- silent no-op */ } + } + + // -- DOM helpers ------------------------------------------------------------- + function applyState(row, btn, isSolved) { + if (isSolved) { + row.classList.add(SOLVED_CLASS); + btn.textContent = GLYPH_SOLVED; + btn.title = 'Mark as undone'; + btn.setAttribute('aria-label', 'Mark as undone'); + } else { + row.classList.remove(SOLVED_CLASS); + btn.textContent = GLYPH_UNSOLVED; + btn.title = 'Mark as done'; + btn.setAttribute('aria-label', 'Mark as done'); + } + } + + /** + * Reorder rows: move all solved rows to the bottom of tbody, preserving + * their relative order. Uses appendChild (move, not clone). + * Called once at init and once per toggle. + */ + function reorderRows(tbody) { + var solved = Array.prototype.filter.call( + tbody.querySelectorAll('tr.' + SOLVED_CLASS), + function (r) { return r.classList.contains(SOLVED_CLASS); } + ); + solved.forEach(function (row) { tbody.appendChild(row); }); + } + + // -- Hide-solved checkbox: instant client-side toggle ----------------------- + function wireHideSolvedCheckbox(tbody) { + var chk = document.getElementById('hide-solved-checkbox'); + if (!chk) return; + + function updateCrackyLink() { + var crackyBtn = document.getElementById('btn-random-cracky'); + if (crackyBtn) { + try { + var url = new URL(crackyBtn.href, window.location.origin); + if (chk.checked) { + url.searchParams.set('hide_solved', '1'); + } else { + url.searchParams.delete('hide_solved'); + } + crackyBtn.href = url.pathname + url.search; + } catch (e) {} + } + } + + chk.addEventListener('change', function () { + var hide = chk.checked; + updateCrackyLink(); + if (tbody) { + Array.prototype.forEach.call( + tbody.querySelectorAll('tr.' + SOLVED_CLASS), + function (row) { row.style.display = hide ? 'none' : ''; } + ); + } + }); + + updateCrackyLink(); + + // Apply on load in case the checkbox starts checked but rows are present + if (chk.checked && tbody) { + Array.prototype.forEach.call( + tbody.querySelectorAll('tr.' + SOLVED_CLASS), + function (row) { row.style.display = 'none'; } + ); + } + } + + // -- Challenge viewing page handler (Issue #182) ---------------------------- + function wireSingleChallengePage(isLoggedIn) { + var btn = document.getElementById('page-mark-solved-btn'); + if (!btn) return; + + var hexid = btn.getAttribute('data-crackme-id'); + if (!hexid) return; + + var statusSpan = document.getElementById('page-progress-status'); + + function updateSingleUI(isSolved) { + var iconSpan = btn.querySelector('.mark-icon'); + var labelSpan = btn.querySelector('.mark-label'); + var glyph = isSolved ? GLYPH_SOLVED : GLYPH_UNSOLVED; + + if (iconSpan) iconSpan.textContent = glyph; + if (labelSpan) labelSpan.textContent = isSolved ? 'Done' : 'Mark as done'; + btn.title = isSolved ? 'Mark it as undone. Only visible to yourself' : 'Mark as done. Only visible to yourself'; + btn.setAttribute('aria-label', isSolved ? 'Mark as undone' : 'Mark as done'); + + if (isSolved) { + btn.classList.add('is-solved'); + btn.classList.add('active'); + } else { + btn.classList.remove('is-solved'); + btn.classList.remove('active'); + } + + if (statusSpan) { + statusSpan.className = isSolved ? 'text-success' : 'text-gray'; + var statusIcon = statusSpan.querySelector('.mark-icon'); + var statusText = statusSpan.querySelector('.mark-text'); + if (statusIcon) statusIcon.textContent = glyph; + if (statusText) statusText.textContent = isSolved ? 'Marked as done' : 'Not done yet'; + } + } + + if (statusSpan) { + statusSpan.addEventListener('click', function (e) { + if (e.target.tagName === 'A') return; + btn.click(); + }); + } + + // Guest: apply localStorage state if logged out + if (!isLoggedIn) { + var stored = lsLoad(); + if (stored.has(hexid)) { + updateSingleUI(true); + } + } + + btn.addEventListener('click', function (e) { + e.preventDefault(); + var wasSolved = btn.classList.contains('active') || btn.classList.contains('is-solved'); + var nextSolved = !wasSolved; + + // Optimistic update + updateSingleUI(nextSolved); + + if (!isLoggedIn) { + var guestStored = lsLoad(); + nextSolved ? guestStored.add(hexid) : guestStored.delete(hexid); + lsSave(guestStored); + return; + } + + btn.disabled = true; + postToggle(hexid, function (err, data) { + btn.disabled = false; + if (err || !data || !data.ok) { + // Rollback on failure + updateSingleUI(wasSolved); + var msg = (data && data.error) ? data.error : 'Network error. Please try again.'; + console.error('[mark_solved] Single toggle failed:', msg); + alert('⚠️ ' + msg); + } + }); + }); + } + + // -- API call ---------------------------------------------------------------- + function postToggle(hexid, callback) { + var csrf = (typeof window.CSRF_TOKEN !== 'undefined') ? window.CSRF_TOKEN : ''; + fetch('/crackme/' + hexid + '/toggle-manual-solve', { + method: 'POST', + headers: { + 'X-CSRFToken': csrf, + 'Content-Type': 'application/json', + }, + credentials: 'same-origin', + }) + .then(function (resp) { return resp.json(); }) + .then(function (data) { callback(null, data); }) + .catch(function (err) { callback(err, null); }); + } + + // -- Main init --------------------------------------------------------------- + document.addEventListener('DOMContentLoaded', function () { + var isLoggedIn = (typeof window.IS_LOGGED_IN !== 'undefined') + ? window.IS_LOGGED_IN + : false; + + // 1. Single challenge viewing page + wireSingleChallengePage(isLoggedIn); + + // 2. List pages (search results / latest) + var tbody = document.getElementById('search-results') + || document.getElementById('content-list'); + if (!tbody) return; // Not a list page + + // For guests: apply localStorage state to any rows the server rendered + // as unsolved (they won't have the solved class yet) + if (!isLoggedIn) { + var stored = lsLoad(); + Array.prototype.forEach.call( + tbody.querySelectorAll('tr.crackme-row[data-crackme-id]'), + function (row) { + var hexid = row.getAttribute('data-crackme-id'); + var btn = row.querySelector('.btn-mark-solved[data-crackme-id]'); + if (!btn) return; + if (stored.has(hexid)) applyState(row, btn, true); + } + ); + } + + // Initial reorder: push solved rows to the bottom + reorderRows(tbody); + + // Wire hide-solved checkbox for instant client feedback + wireHideSolvedCheckbox(tbody); + + // -- Event delegation: single listener on ------------------------- + tbody.addEventListener('click', function (e) { + var btn = e.target.closest('.btn-mark-solved'); + if (!btn) return; + + var hexid = btn.getAttribute('data-crackme-id'); + if (!hexid) return; + + var row = btn.closest('tr.crackme-row'); + if (!row) return; + + var wasAlreadySolved = row.classList.contains(SOLVED_CLASS); + var nextSolved = !wasAlreadySolved; + + // -- Optimistic update (instant feedback) ------------------------------- + applyState(row, btn, nextSolved); + + // Re-sort: move row to its correct zone immediately + if (nextSolved) { + tbody.appendChild(row); // sink to bottom + } else { + // Rise: insert before the first currently-solved row + var firstSolved = tbody.querySelector('tr.' + SOLVED_CLASS); + if (firstSolved && firstSolved !== row) { + tbody.insertBefore(row, firstSolved); + } + } + + // -- Hide-solved checkbox: hide the row if checkbox is active ---------- + var chk = document.getElementById('hide-solved-checkbox'); + if (nextSolved && chk && chk.checked) { + row.style.display = 'none'; + } else { + row.style.display = ''; + } + + if (!isLoggedIn) { + // Guest: persist to localStorage only + var guestStored = lsLoad(); + nextSolved ? guestStored.add(hexid) : guestStored.delete(hexid); + lsSave(guestStored); + return; + } + + // -- Authenticated: sync to server ------------------------------------ + btn.disabled = true; // prevent double-click during flight + + postToggle(hexid, function (err, data) { + btn.disabled = false; + + if (err || !data || !data.ok) { + // -- Rollback on failure -------------------------------------------- + applyState(row, btn, wasAlreadySolved); + + // Re-sort back + if (wasAlreadySolved) { + tbody.appendChild(row); + } else { + var firstS = tbody.querySelector('tr.' + SOLVED_CLASS); + if (firstS && firstS !== row) tbody.insertBefore(row, firstS); + } + + // Restore hide-state + if (wasAlreadySolved && chk && chk.checked) { + row.style.display = 'none'; + } else { + row.style.display = ''; + } + + var msg = (data && data.error) ? data.error : 'Network error. Please try again.'; + console.error('[mark_solved] Toggle failed:', msg); + + // Insert a dismissible notice above the table (no alert()) + var notice = document.createElement('p'); + notice.style.cssText = 'color:#e74c3c;font-size:0.85rem;margin:4px 0;'; + notice.textContent = '\u26a0\ufe0f ' + msg; + var table = tbody.closest('.table-responsive') || tbody.parentElement; + if (table && table.parentElement) { + table.parentElement.insertBefore(notice, table); + setTimeout(function () { notice.remove(); }, 4000); + } + } + // On success: the optimistic update is already applied, nothing to do. + }); + }); + }); + +})(); diff --git a/templates/crackme/lasts.html b/templates/crackme/lasts.html index 93fcbd2..f45b44e 100644 --- a/templates/crackme/lasts.html +++ b/templates/crackme/lasts.html @@ -1,7 +1,9 @@ {% extends "base.html" %} {% block title %}Latest crackmes{% endblock %} {% block page_title %}Latest crackmes{% endblock %} -{% block head %}{% endblock %} +{% block head %} + +{% endblock %} {% block content %}
@@ -23,11 +25,13 @@

Latest Crackmes Downloads Writeups Comments + {% if is_logged_in %}Done{% endif %} {% for crackme in crackmes %} - + {{ crackme.name }} {{ crackme.author }} {{ crackme.lang }} @@ -40,6 +44,19 @@

Latest Crackmes {{ crackme.nbdownloads|default(0) }} [↓] {{ crackme.nbsolutions }} {{ crackme.nbcomments }} + {% if is_logged_in %} + + {% if crackme.hexid in official_solves %} + 🔒 + {% else %} + + {% endif %} + + {% endif %} {% endfor %} @@ -63,4 +80,10 @@

Latest Crackmes + +{% endblock %} diff --git a/templates/crackme/read.html b/templates/crackme/read.html index 0464f7b..4fcd22f 100644 --- a/templates/crackme/read.html +++ b/templates/crackme/read.html @@ -3,6 +3,7 @@ {% block page_title %}{{ username }}'s {{ name }}{% endblock %} {% block head %} + {% endblock %} {% block content %} {% endblock %} -{% block foot %}{% endblock %} +{% block foot %} + + +{% endblock %} diff --git a/templates/search/search.html b/templates/search/search.html index 7b6c979..16cd2ce 100644 --- a/templates/search/search.html +++ b/templates/search/search.html @@ -1,7 +1,9 @@ {% extends "base.html" %} {% block title %}Search a crackme{% endblock %} {% block page_title %}Search a crackme{% endblock %} -{% block head %}{% endblock %} +{% block head %} + +{% endblock %} {% block content %} @@ -178,9 +180,27 @@

Crackme search

+ {% if is_logged_in %} +
+
+ +
+
+ +
+
+ {% endif %}
Clear - I'm feeling cracky + I'm feeling cracky
@@ -199,11 +219,13 @@

Crackme search

Downloads {% if search_params.get('sort_by') == 'downloads' %}{{ '▲' if search_params.get('sort_order') == 'asc' else '▼' }}{% endif %} Writeups {% if search_params.get('sort_by') == 'solutions' %}{{ '▲' if search_params.get('sort_order') == 'asc' else '▼' }}{% endif %} Comments {% if search_params.get('sort_by') == 'comments' %}{{ '▲' if search_params.get('sort_order') == 'asc' else '▼' }}{% endif %} + {% if is_logged_in %}Done{% endif %} {% for crackme in crackmes %} - + {{ crackme.name }} {{ crackme.author }} {{ crackme.lang }} @@ -216,6 +238,19 @@

Crackme search

{{ crackme.nbdownloads|default(0) }} [↓] {{ crackme.nbsolutions }} {{ crackme.nbcomments }} + {% if is_logged_in %} + + {% if crackme.hexid in official_solves %} + 🔒 + {% else %} + + {% endif %} + + {% endif %} {% endfor %} @@ -248,6 +283,7 @@

Crackme search

+ Showing all {{ crackmes|length }} results @@ -278,6 +314,7 @@

Crackme search

+ {% if page > 1 %} @@ -305,6 +342,7 @@

Crackme search

+ {% else %} @@ -336,6 +374,7 @@

Crackme search

+ {% else %} @@ -349,6 +388,12 @@

Crackme search

{% endblock %} {% block foot %} + +