diff --git a/.gitignore b/.gitignore index 1dc4bb2..bc7c8f4 100644 --- a/.gitignore +++ b/.gitignore @@ -46,6 +46,8 @@ static/crackme/* static/solution/* !static/crackme/.gitkeep !static/solution/.gitkeep +# Private source archives for auto-validated crackmes (reviewers only) +private/ config/config.json users.json .env diff --git a/app/controllers/comment.py b/app/controllers/comment.py index bd34700..0e93576 100644 --- a/app/controllers/comment.py +++ b/app/controllers/comment.py @@ -9,7 +9,9 @@ import bleach from app.models.comment import comment_create, comment_by_id, comment_set_spoiler, get_thread_participants from app.models.solution import get_solution_authors -from app.models.crackme import crackme_by_hexid, crackme_increment_comments +from app.models.crackme import ( + crackme_by_hexid, crackme_increment_comments, crackme_is_auto_validated +) from app.models.notification import notification_add from app.models.errors import ErrNoResult from app.services.recaptcha import verify as verify_recaptcha @@ -51,6 +53,23 @@ def leave_comment(hexid): """Post a comment on a crackme.""" username = session.get('name') + try: + crackme = crackme_by_hexid(hexid) + except ErrNoResult: + flash('Crackme not found.', FLASH_ERROR) + return redirect(f'/crackme/{hexid}') + except Exception as e: + print(f"Error getting crackme: {e}") + flash('Comment creation failed. Please try again later.', FLASH_ERROR) + return redirect(f'/crackme/{hexid}') + + if crackme_is_auto_validated(crackme): + flash( + 'Comments are disabled for auto-validated crackmes.', + FLASH_ERROR + ) + return redirect(f'/crackme/{hexid}') + # Validate required fields is_valid, missing = validate_required(request.form, ['comment']) if not is_valid: @@ -92,7 +111,6 @@ def leave_comment(hexid): # Send notification to crackme author and handle @mentions try: - crackme = crackme_by_hexid(hexid) crackme_author = crackme.get('author') crackme_name = crackme['name'] diff --git a/app/controllers/crackme.py b/app/controllers/crackme.py index 4675007..f87b4d4 100644 --- a/app/controllers/crackme.py +++ b/app/controllers/crackme.py @@ -4,14 +4,14 @@ import os from html import escape as html_escape -from flask import Blueprint, render_template, request, redirect, flash, session, abort +from flask import Blueprint, render_template, request, redirect, flash, jsonify, session, abort from werkzeug.utils import secure_filename import bleach from app.models.crackme import ( crackme_by_hexid, last_crackmes, crackme_create_prepare, crackme_insert, crackme_delete_by_hexid, crackme_by_user_and_name, crackme_update_difficulty, crackme_update_quality, crackme_increment_downloads, - crackme_update + crackme_update, crackme_is_auto_validated ) from app.models.solution import solutions_by_crackme from app.models.comment import comments_by_crackme @@ -20,19 +20,39 @@ from app.models.label_request import ( label_request_create, pending_label_requests_by_user_and_crackme ) +from app.models.solve import ( + solve_by_user_and_crackme, solve_create, count_solves_by_crackme, + solves_by_crackme +) +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.errors import ErrNoResult from app.services.recaptcha import verify as verify_recaptcha -from app.services.limiter import limit -from app.services.view import FLASH_ERROR, FLASH_SUCCESS, validate_required +from app.services.limiter import configured_limit, limit +from app.services.view import FLASH_ERROR, FLASH_SUCCESS, FLASH_NOTICE, validate_required 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, is_unsupported_archive -from app.services.discord import notify_new_crackme +from app.services.archive import ( + is_archive_password_protected, is_single_file_archive, + is_unsupported_archive, is_zip_archive, +) +from app.services.discord import ( + notify_flag_solved, notify_flag_submission, notify_new_crackme +) +from app.services.flag import ( + FLAG_FORMAT_HINT, flags_match, is_valid_flag_format, normalize_flag +) +from app.services.points import points_for_solve, solve_difficulty from app.controllers.decorators import login_required crackme_bp = Blueprint('crackme', __name__) # Upload folder for crackmes UPLOAD_FOLDER = 'tmp/crackme' +# Source archives for auto-validated crackmes. Never served: this directory sits +# outside static/ so the only way to read one is the reviewer download route. +SOURCE_UPLOAD_FOLDER = 'private/crackme_source' MAX_FILE_SIZE = 10 * 1024 * 1024 # 10MB @@ -57,6 +77,35 @@ def crackme_view(hexid): # Get current user for edit permission check usersess = session.get('name') + # Flag submission panel. Only auto-validated crackmes pay for these extra + # queries; everything else renders exactly as before. + auto_validation = crackme_is_auto_validated(crackme) + nbsolves = 0 + nbattempts = 0 + solves = [] + user_solve = None + if auto_validation: + try: + nbsolves = count_solves_by_crackme(hexid) + nbattempts = count_flag_submissions_by_crackme(hexid) + solve_records = solves_by_crackme(hexid) + solvers = users_by_hexids( + solve['user_hexid'] for solve in solve_records + ) + solves = [ + { + 'solve': solve, + 'username': solvers.get(solve['user_hexid'], {}) + .get('name', 'Deleted user'), + } + for solve in solve_records + ] + viewer_hexid = _user_hexid(usersess) if usersess else None + if viewer_hexid: + user_solve = solve_by_user_and_crackme(viewer_hexid, hexid) + except Exception as e: + print(f"Error getting solve data: {e}") + # Build mention targets for @mention autocomplete (author + commenters + solution authors) mention_targets = {crackme.get('author', '')} for comment in comments: @@ -87,9 +136,29 @@ def crackme_view(hexid): labels=crackme.get('labels', []), label_groups=get_label_groups(), labels_dataset_url=get_dataset_url(), + auto_validation=auto_validation, + nbsolves=nbsolves, + nbattempts=nbattempts, + solves=solves, + user_solve=user_solve, + solve_points=points_for_solve(crackme) if auto_validation else 0, + flag_format_hint=FLAG_FORMAT_HINT, usersess=usersess) +def _user_hexid(username): + """Resolve a username to the immutable id solves are keyed by. + + Returns None when the user can't be resolved, which callers treat as "no + solve" rather than an error. + """ + try: + user = user_by_name(username) + except Exception: + return None + return user.get('hexid') or str(user['_id']) + + @crackme_bp.route('/lasts') def last_crackmes_redirect(): """Redirect /lasts to /lasts/1.""" @@ -144,6 +213,48 @@ def upload_crackme_get(): return render_template('crackme/create.html', label_groups=get_label_groups()) +def _upload_rejected(message): + """Reject an upload without throwing away what the user typed. + + An AJAX submission gets the message as JSON and the browser keeps the page + (and the files the user picked) untouched; a plain form post falls back to + re-rendering the form with the submitted values filled back in. Either way a + single missing field no longer costs someone the whole form. + """ + if _wants_json(): + return jsonify({'ok': False, 'error': message}), 400 + + flash(message, FLASH_ERROR) + return render_template('crackme/create.html', + label_groups=get_label_groups(), + form=_submitted_form_values()) + + +def _wants_json(): + """True when the upload form posted in the background rather than navigating.""" + return request.headers.get('X-Requested-With') == 'XMLHttpRequest' + + +def _submitted_form_values(): + """The submitted values, shaped for re-rendering the upload form. + + File inputs are deliberately absent: browsers won't let a server refill them, + which is exactly why the form posts over fetch when it can. + """ + return { + 'name': request.form.get('name', ''), + 'info': request.form.get('info', ''), + 'lang': request.form.get('lang', ''), + 'arch': request.form.get('arch', ''), + 'platform': request.form.get('platform', ''), + 'difficulty': request.form.get('difficulty', ''), + 'labels': normalize_labels(request.form.getlist('labels')), + 'auto_validation': bool(request.form.get('auto_validation')), + 'flag': request.form.get('flag', ''), + 'points': request.form.get('points', ''), + } + + @crackme_bp.route('/upload/crackme', methods=['POST']) @login_required @limit("10 per day", key_func=lambda: session.get('name')) @@ -155,8 +266,7 @@ def upload_crackme_post(): required = ['name', 'info', 'lang', 'difficulty', 'platform', 'arch'] is_valid, missing = validate_required(request.form, required) if not is_valid: - flash(f'Field missing: {missing}', FLASH_ERROR) - return render_template('crackme/create.html', label_groups=get_label_groups()) + return _upload_rejected(f'Field missing: {missing}') name = bleach.clean(request.form.get('name', '')) info = bleach.clean(request.form.get('info', '')) @@ -174,46 +284,74 @@ def upload_crackme_post(): if diff_int < 1 or diff_int > 6: raise ValueError() except (ValueError, TypeError): - flash('Wrong difficulty', FLASH_ERROR) - return render_template('crackme/create.html', label_groups=get_label_groups()) + return _upload_rejected('Wrong difficulty') # Validate reCAPTCHA if not verify_recaptcha(request): - flash('reCAPTCHA invalid!', FLASH_ERROR) - return render_template('crackme/create.html', label_groups=get_label_groups()) + return _upload_rejected('reCAPTCHA invalid!') # Check for file if 'file' not in request.files: - flash('Field missing: file', FLASH_ERROR) - return render_template('crackme/create.html', label_groups=get_label_groups()) + return _upload_rejected('Field missing: file') file = request.files['file'] if file.filename == '': - flash('Field missing: file', FLASH_ERROR) - return render_template('crackme/create.html', label_groups=get_label_groups()) + return _upload_rejected('Field missing: file') # Read file data file_data = file.read() # Check file size if len(file_data) > MAX_FILE_SIZE: - flash('This file is too large!', FLASH_ERROR) - return render_template('crackme/create.html', label_groups=get_label_groups()) + return _upload_rejected('This file is too large!') # Check for unsupported archive formats (RAR, tar, etc.) if is_unsupported_archive(file_data): - flash('RAR and tar archives are not supported. Please upload a ZIP file for multiple files, or upload single files directly.', FLASH_ERROR) - return render_template('crackme/create.html', label_groups=get_label_groups()) + return _upload_rejected('RAR and tar archives are not supported. Please upload a ZIP file for multiple files, or upload single files directly.') # Check for password protection if is_archive_password_protected(file_data): - flash('Password-protected archives are not allowed. Do NOT add a password yourself - the server handles this automatically.', FLASH_ERROR) - return render_template('crackme/create.html', label_groups=get_label_groups()) + return _upload_rejected('Password-protected archives are not allowed. Do NOT add a password yourself - the server handles this automatically.') # Check for single-file archives if is_single_file_archive(file_data): - flash('Archives containing only one file are not allowed. Please upload the file directly without wrapping it in an archive.', FLASH_ERROR) - return render_template('crackme/create.html', label_groups=get_label_groups()) + return _upload_rejected('Archives containing only one file are not allowed. Please upload the file directly without wrapping it in an archive.') + + # Auto-validation opt-in: the flag users will submit, plus the private source + # archive a reviewer needs to confirm that flag is actually the right one. + flag = None + source_data = None + source_filename = None + official_difficulty = None + if request.form.get('auto_validation'): + flag = normalize_flag(request.form.get('flag', '')) + if not is_valid_flag_format(flag): + return _upload_rejected(f'Invalid flag format. {FLAG_FORMAT_HINT}') + + points_value = request.form.get('points') or str(diff_int * 100) + try: + points = int(points_value) + except (TypeError, ValueError): + return _upload_rejected('Points must be a whole number from 100 to 600.') + if points < 100 or points > 600: + return _upload_rejected('Points must be a whole number from 100 to 600.') + official_difficulty = points / 100 + + source = request.files.get('source') + if source is None or source.filename == '': + return _upload_rejected('Auto-validation needs a source archive so reviewers can verify the flag.') + + source_data = source.read() + if len(source_data) > MAX_FILE_SIZE: + return _upload_rejected('The source archive is too large!') + if is_unsupported_archive(source_data): + return _upload_rejected('RAR and tar source archives are not supported. Please upload a ZIP file.') + if not is_zip_archive(source_data): + return _upload_rejected('The reviewer verification file must be a valid ZIP archive.') + if is_archive_password_protected(source_data): + return _upload_rejected('Password-protected source archives are not allowed - reviewers need to be able to open it.') + + source_filename = secure_filename(source.filename) or "source" # Store the uploaded file size size = len(file_data) @@ -221,8 +359,7 @@ def upload_crackme_post(): # Check for duplicate pending submission try: crackme_by_user_and_name(username, name, visible=False) - flash('You already have a pending crackme with this name. Please wait for review or choose a different name.', FLASH_ERROR) - return render_template('crackme/create.html', label_groups=get_label_groups()) + return _upload_rejected('You already have a pending crackme with this name. Please wait for review or choose a different name.') except ErrNoResult: pass # No duplicate, continue @@ -231,13 +368,17 @@ def upload_crackme_post(): # Prepare crackme try: - crackme = crackme_create_prepare(name, info, username, lang, arch, platform, size, original_filename, labels=labels) + crackme = crackme_create_prepare(name, info, username, lang, arch, platform, size, original_filename, + labels=labels, flag=flag, + source_original_filename=source_filename, + official_difficulty=official_difficulty) except Exception as e: print(f"Error preparing crackme: {e}") abort(500) # Create path using hexid only safe_path = os.path.join(UPLOAD_FOLDER, crackme['hexid']) + source_path = os.path.join(SOURCE_UPLOAD_FOLDER, crackme['hexid']) # Ensure upload directory exists os.makedirs(UPLOAD_FOLDER, exist_ok=True) @@ -248,15 +389,32 @@ def upload_crackme_post(): f.write(file_data) except Exception as e: print(f"File write error: {e}") - flash('Failed to save file. Please try again.', FLASH_ERROR) - return render_template('crackme/create.html', label_groups=get_label_groups()) + return _upload_rejected('Failed to save file. Please try again.') + + if source_data is not None: + try: + os.makedirs(SOURCE_UPLOAD_FOLDER, exist_ok=True) + with open(source_path, 'wb') as f: + f.write(source_data) + except Exception as e: + print(f"Source file write error: {e}") + os.remove(safe_path) + return _upload_rejected('Failed to save the source archive. Please try again.') + + def _cleanup_files(): + for path in (safe_path, source_path if source_data is not None else None): + if path: + try: + os.remove(path) + except OSError: + pass # Insert crackme into database try: crackme_insert(crackme) except Exception as e: print(f"Database insert error: {e}") - os.remove(safe_path) # Cleanup + _cleanup_files() # Cleanup abort(500) # Create ratings @@ -265,7 +423,7 @@ def upload_crackme_post(): rating_quality_create(username, crackme['hexid'], 4) except Exception as e: print(f"Rating creation error: {e}") - os.remove(safe_path) + _cleanup_files() crackme_delete_by_hexid(crackme['hexid']) rating_difficulty_delete_by_crackme(crackme['hexid']) abort(500) @@ -289,10 +447,127 @@ def upload_crackme_post(): except Exception as e: print(f"Discord notification error: {e}") + # Post/redirect/get: the confirmation lives at its own URL, so the browser + # (and the background submit above, which just follows the redirect) can't + # re-post the upload by refreshing. + session['submitted_crackme'] = crackme['name'] + if _wants_json(): + return jsonify({'ok': True, 'redirect': '/upload/crackme/submitted'}) + return redirect('/upload/crackme/submitted') + + +@crackme_bp.route('/upload/crackme/submitted', methods=['GET']) +@login_required +def upload_crackme_submitted(): + """Confirm a crackme upload that just went through.""" + name = session.pop('submitted_crackme', None) + if not name: + return redirect('/upload/crackme') + return render_template('submission/success.html', submission_type='Crackme', - name=crackme['name'], - username=username) + name=name, + username=session.get('name')) + + +@crackme_bp.route('/crackme//solve', methods=['POST']) +@login_required +# Guessing a flag is meant to be impossible, but a slow attempt rate makes that +# true even for a badly chosen flag. +@limit(configured_limit('FlagSubmissions'), key_func=lambda: session.get('name')) +def submit_flag(hexid): + """Validate a submitted flag and, if correct, record the solve.""" + username = session.get('name') + flag = normalize_flag(request.form.get('flag', '')) + + def log_result(result, user_hexid=None): + """Persist the submitted flag and its validation outcome.""" + try: + flag_submission_create( + user_hexid, username, hexid, flag, result + ) + 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) + except ErrNoResult: + abort(404) + except Exception as e: + print(f"Error getting crackme: {e}") + abort(500) + + if not crackme_is_auto_validated(crackme): + log_result('not_enabled') + flash('This crackme does not accept flag submissions.', FLASH_ERROR) + return redirect(f'/crackme/{hexid}') + + # Authors already know their own flag; awarding them points for it would + # make the scoreboard meaningless. + if crackme.get('author') == username: + log_result('own_crackme') + flash("You can't submit a flag for your own crackme.", FLASH_ERROR) + return redirect(f'/crackme/{hexid}') + + user_hexid = _user_hexid(username) + if not user_hexid: + log_result('account_unavailable') + flash('Could not verify your account. Please log in again.', FLASH_ERROR) + return redirect(f'/crackme/{hexid}') + + try: + if solve_by_user_and_crackme(user_hexid, hexid): + log_result('already_solved', user_hexid) + flash('You have already solved this crackme.', FLASH_NOTICE) + return redirect(f'/crackme/{hexid}') + except Exception as e: + print(f"Error checking existing solve: {e}") + abort(500) + + if not is_valid_flag_format(flag): + log_result('invalid_format', user_hexid) + flash(f'That is not a valid flag. {FLAG_FORMAT_HINT}', FLASH_ERROR) + return redirect(f'/crackme/{hexid}') + + if not flags_match(crackme.get('flag'), flag): + log_result('incorrect', user_hexid) + flash('Wrong flag. Keep trying!', FLASH_ERROR) + return redirect(f'/crackme/{hexid}') + + points = points_for_solve(crackme) + try: + solve_create(user_hexid, hexid, points, solve_difficulty(crackme)) + except Exception as e: + print(f"Error recording solve: {e}") + abort(500) + + log_result('correct', user_hexid) + + try: + notify_flag_solved( + 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}") + + flash(f'Correct! You earned {points} points.', FLASH_SUCCESS) + return redirect(f'/crackme/{hexid}') @crackme_bp.route('/crackme//edit', methods=['GET']) diff --git a/app/controllers/rating.py b/app/controllers/rating.py index 2136f79..0b8b942 100644 --- a/app/controllers/rating.py +++ b/app/controllers/rating.py @@ -3,12 +3,18 @@ """ from flask import Blueprint, request, redirect, flash, session -from app.models.crackme import crackme_update_difficulty, crackme_update_quality +from app.models.crackme import ( + crackme_by_hexid, crackme_is_auto_validated, + crackme_update_difficulty, crackme_update_quality +) +from app.models.errors import ErrNoResult from app.models.rating import ( is_already_rated_difficulty, is_already_rated_quality, rating_difficulty_create, rating_difficulty_set_rating, rating_quality_create, rating_quality_set_rating ) +from app.models.solve import solve_by_user_and_crackme +from app.models.user import user_by_name from app.services.view import FLASH_ERROR, FLASH_SUCCESS, validate_required from app.controllers.decorators import login_required @@ -21,6 +27,21 @@ def rate_difficulty(hexid): """Rate a crackme's difficulty.""" username = session.get('name') + try: + crackme = crackme_by_hexid(hexid) + except ErrNoResult: + return redirect(f'/crackme/{hexid}'), 404 + except Exception as e: + print(f"Rating error: {e}") + return redirect(f'/crackme/{hexid}'), 500 + + if crackme_is_auto_validated(crackme): + flash( + 'Difficulty ratings are disabled for auto-validated crackmes.', + FLASH_ERROR + ) + return redirect(f'/crackme/{hexid}') + # Validate required fields is_valid, missing = validate_required(request.form, ['difficulty']) if not is_valid: @@ -61,6 +82,30 @@ def rate_quality(hexid): """Rate a crackme's quality.""" username = session.get('name') + try: + crackme = crackme_by_hexid(hexid) + except ErrNoResult: + return redirect(f'/crackme/{hexid}'), 404 + except Exception as e: + print(f"Rating error: {e}") + return redirect(f'/crackme/{hexid}'), 500 + + if crackme_is_auto_validated(crackme): + try: + user = user_by_name(username) + user_hexid = user.get('hexid') or str(user['_id']) + has_solved = solve_by_user_and_crackme(user_hexid, hexid) + except Exception as e: + print(f"Rating error: {e}") + return redirect(f'/crackme/{hexid}'), 500 + + if not has_solved: + flash( + 'Solve this crackme before rating it.', + FLASH_ERROR + ) + return redirect(f'/crackme/{hexid}') + # Validate required fields is_valid, missing = validate_required(request.form, ['quality']) if not is_valid: diff --git a/app/controllers/solution.py b/app/controllers/solution.py index 96ec93f..1c382fe 100644 --- a/app/controllers/solution.py +++ b/app/controllers/solution.py @@ -9,10 +9,10 @@ import os from html import escape as html_escape -from flask import Blueprint, render_template, request, redirect, flash, session, abort, current_app, Response +from flask import Blueprint, render_template, request, redirect, flash, session, abort, current_app, Response, jsonify from werkzeug.utils import secure_filename import bleach -from app.models.crackme import crackme_by_hexid +from app.models.crackme import crackme_by_hexid, crackme_is_auto_validated from app.models.solution import solution_create, solution_exists, solution_by_hexid from app.models.notification import notification_add from app.models.errors import ErrNoResult @@ -68,12 +68,32 @@ def _send_notifications_and_render_success(username, crackme): except Exception as e: print(f"Notification error: {e}") + if _wants_json(): + session['submitted_writeup'] = crackme['name'] + return jsonify({ + 'ok': True, + 'redirect': '/upload/solution/submitted', + }) + return render_template('submission/success.html', submission_type='Writeup', name=crackme['name'], username=username) +def _wants_json(): + """True when the editor submitted in the background.""" + return request.headers.get('X-Requested-With') == 'XMLHttpRequest' + + +def _submission_rejected(message, redirect_url): + """Reject without navigating away from a background-submitted editor.""" + if _wants_json(): + return jsonify({'ok': False, 'error': message}), 400 + flash(message, FLASH_ERROR) + return redirect(redirect_url) + + def _validate_attachment(file): """Validate an uploaded attachment. Returns (data, error_message). @@ -110,6 +130,12 @@ def _validate_attachment(file): def upload_solution_get(hexidcrackme): """Display the solution submission form (markdown editor + optional attachment).""" crackme = _get_crackme_or_abort(hexidcrackme) + if crackme_is_auto_validated(crackme): + flash( + 'Writeups are disabled for auto-validated crackmes.', + FLASH_ERROR + ) + return redirect(f'/crackme/{hexidcrackme}') return render_template('solution/editor.html', hexidcrackme=hexidcrackme, username=crackme.get('author', ''), @@ -135,30 +161,42 @@ def upload_solution_post(hexidcrackme): username = session.get('name') redirect_url = f'/upload/solution/{hexidcrackme}' + if crackme_is_auto_validated(crackme): + return _submission_rejected( + 'Writeups are disabled for auto-validated crackmes.', + f'/crackme/{hexidcrackme}', + ) + # Check if user already submitted a solution if solution_exists(username, crackme['_id']): - flash("You've already submitted a solution to this crackme", FLASH_ERROR) - return redirect(redirect_url) + return _submission_rejected( + "You've already submitted a solution to this crackme", redirect_url + ) if not verify_recaptcha(request): - flash('reCAPTCHA invalid!', FLASH_ERROR) - return redirect(redirect_url) + return _submission_rejected('reCAPTCHA invalid!', redirect_url) # Summary info = bleach.clean(request.form.get('info', '')) if len(info) > MAX_INFO_LENGTH: - flash(f'Info field exceeds maximum length of {MAX_INFO_LENGTH} characters.', FLASH_ERROR) - return redirect(redirect_url) + return _submission_rejected( + f'Info field exceeds maximum length of {MAX_INFO_LENGTH} characters.', + redirect_url, + ) # Inline markdown content (optional) content = request.form.get('content', '').strip() if content: if len(content) < MIN_CONTENT_LENGTH: - flash(f'Your writeup is too short. Please write at least {MIN_CONTENT_LENGTH} characters.', FLASH_ERROR) - return redirect(redirect_url) + return _submission_rejected( + f'Your writeup is too short. Please write at least {MIN_CONTENT_LENGTH} characters.', + redirect_url, + ) if len(content) > MAX_CONTENT_LENGTH: - flash(f'Your writeup exceeds the maximum length of {MAX_CONTENT_LENGTH:,} characters.', FLASH_ERROR) - return redirect(redirect_url) + return _submission_rejected( + f'Your writeup exceeds the maximum length of {MAX_CONTENT_LENGTH:,} characters.', + redirect_url, + ) else: content = None @@ -170,14 +208,15 @@ def upload_solution_post(hexidcrackme): if has_file: data, error = _validate_attachment(file) if error: - flash(error, FLASH_ERROR) - return redirect(redirect_url) + return _submission_rejected(error, redirect_url) original_filename = secure_filename(file.filename) or "unnamed" # A solution must have a writeup body: markdown content, an attachment, or both. if content is None and not has_file: - flash('Please write a markdown writeup or attach a file (or both).', FLASH_ERROR) - return redirect(redirect_url) + return _submission_rejected( + 'Please write a markdown writeup or attach a file (or both).', + redirect_url, + ) try: solution = solution_create(info, username, crackme, content=content, original_filename=original_filename) @@ -198,12 +237,27 @@ def upload_solution_post(hexidcrackme): get_collection('solution').delete_one({'hexid': solution['hexid']}) except Exception as cleanup_error: print(f"Failed to roll back solution record: {cleanup_error}") - flash('An error occurred on the server. Please try again later.', FLASH_ERROR) - return redirect(redirect_url) + return _submission_rejected( + 'An error occurred on the server. Please try again later.', + redirect_url, + ) return _send_notifications_and_render_success(username, crackme) +@solution_bp.route('/upload/solution/submitted', methods=['GET']) +@login_required +def upload_solution_submitted(): + """Confirm a writeup submitted by the background editor.""" + name = session.pop('submitted_writeup', None) + if not name: + return redirect('/') + return render_template( + 'submission/success.html', submission_type='Writeup', + name=name, username=session.get('name'), + ) + + @solution_bp.route('/solution/', methods=['GET']) def view_solution(hexid): """Display a solution's writeup page (public).""" diff --git a/app/controllers/user.py b/app/controllers/user.py index 84bb8d7..3c82fd2 100644 --- a/app/controllers/user.py +++ b/app/controllers/user.py @@ -4,9 +4,10 @@ from flask import Blueprint, render_template, session, abort from app.models.user import user_by_name -from app.models.crackme import crackmes_by_user +from app.models.crackme import crackmes_by_user, crackmes_by_hexids from app.models.solution import solutions_by_user from app.models.comment import comments_by_user +from app.models.solve import solves_by_user from app.models.errors import ErrNoResult user_bp = Blueprint('user', __name__) @@ -46,6 +47,22 @@ def user_profile(name): 'crackmename': solution.get('crackmename', '') }) + # Solved crackmes and the score they add up to. Names are looked up in + # one batch rather than stored on the solve, so a renamed or deleted + # crackme can't leave a stale title behind on the profile. + solves = solves_by_user(user.get('hexid') or str(user['_id'])) + solved_crackmes = crackmes_by_hexids([s['crackme_hexid'] for s in solves]) + solves_extended = [ + { + 'solve': solve, + 'crackmehexid': solve['crackme_hexid'], + 'crackmename': solved_crackmes.get(solve['crackme_hexid'], {}) + .get('name', 'Unknown crackme'), + } + for solve in solves + ] + score = sum(solve.get('points', 0) for solve in solves) + # Check if viewing own profile session_username = session.get('name', '') viewing_own_page = session_username and session_username == actual_username @@ -55,9 +72,11 @@ def user_profile(name): NbCrackmes=nb_crackmes, NbSolutions=nb_solutions, NbComments=nb_comments, + Score=score, crackmes=crackmes, solutions=solutions_extended, comments=comments, + solves=solves_extended, viewingOwnPage=viewing_own_page) except Exception as e: diff --git a/app/models/crackme.py b/app/models/crackme.py index 3780db0..d72a91a 100644 --- a/app/models/crackme.py +++ b/app/models/crackme.py @@ -263,6 +263,31 @@ def crackmes_by_user(username): .sort('created_at', DESCENDING)) +def crackmes_by_hexids(hexids): + """Look up several crackmes at once. + + Args: + hexids: An iterable of crackme hex IDs. + + Returns: + A dict of hexid -> crackme document, holding only the ids that exist and + are visible. Callers listing references to crackmes (a user's solves, + say) use this to resolve names in one query instead of one per row. + """ + if not check_connection(): + raise ErrUnavailable("Database is unavailable") + + hexids = list(hexids) + if not hexids: + return {} + + collection = get_collection('crackme') + return { + crackme['hexid']: crackme + for crackme in collection.find({'hexid': {'$in': hexids}, 'visible': True}) + } + + def crackme_by_user_and_name(username, name, visible=True): """Get crackme by user and name.""" if not check_connection(): @@ -281,8 +306,19 @@ def crackme_by_user_and_name(username, name, visible=True): return result -def crackme_create_prepare(name, info, username, lang, arch, platform, size, original_filename, labels=None): - """Prepare a crackme object without inserting it.""" +def crackme_create_prepare(name, info, username, lang, arch, platform, size, original_filename, + labels=None, flag=None, source_original_filename=None, + official_difficulty=None): + """Prepare a crackme object without inserting it. + + Args: + flag: The author's flag when they opted into auto-validation, else None. + Its presence is what marks a crackme as auto-validated -- there is + no separate flag to keep in sync. Stored in cleartext for reviewers; + never rendered on the public site. + source_original_filename: Filename of the private source archive that + accompanies an auto-validated submission (reviewers only). + """ if not check_connection(): raise ErrUnavailable("Database is unavailable") @@ -307,10 +343,20 @@ def crackme_create_prepare(name, info, username, lang, arch, platform, size, ori 'platform': platform, 'size': size, 'original_filename': original_filename, - 'labels': labels or [] + 'labels': labels or [], + 'flag': flag, + 'source_original_filename': source_original_filename, + # For auto-validation, the author chooses an integer point value and we + # store points / 100 here so the existing scoring field stays canonical. + 'official_difficulty': official_difficulty, } +def crackme_is_auto_validated(crackme): + """Return True if a crackme accepts flag submissions.""" + return bool(crackme.get('flag')) + + def crackme_insert(crackme): """Insert a prepared crackme into the database.""" if not check_connection(): @@ -408,6 +454,44 @@ def crackme_set_labels(hexid, labels): return old_labels +def crackme_set_official_difficulty(hexid, difficulty): + """Store the difficulty level a reviewer assigned to a crackme. + + This is the number solves are priced at (see :mod:`app.services.points`). + It is deliberately separate from the ``difficulty`` field, which is the + community rating average and keeps moving as people rate the crackme. + + Args: + hexid: The hex ID of the crackme + difficulty: Difficulty value 1-6, with up to two decimal places + + Returns: + True if the crackme was updated, False if it was not found or the + difficulty was out of range. + """ + if not check_connection(): + raise ErrUnavailable("Database is unavailable") + + try: + difficulty = float(difficulty) + except (TypeError, ValueError): + return False + + if difficulty < 1 or difficulty > 6: + return False + + # Point values are whole numbers, so stored difficulty has at most two + # decimal places (points / 100). + if abs(round(difficulty * 100) - difficulty * 100) > 1e-9: + return False + + result = get_collection('crackme').update_one( + {'hexid': hexid}, + {'$set': {'official_difficulty': difficulty}} + ) + return result.matched_count == 1 + + def crackme_by_hexid_any(hexid): """Get crackme by hex ID regardless of visibility status. diff --git a/app/models/flag_submission.py b/app/models/flag_submission.py new file mode 100644 index 0000000..a70eb59 --- /dev/null +++ b/app/models/flag_submission.py @@ -0,0 +1,52 @@ +"""Persistent audit records for user flag-submission attempts.""" + +from datetime import datetime, timezone + +from bson import ObjectId + +from app.models.errors import ErrUnavailable +from app.services.database import get_collection, check_connection + + +def flag_submission_create( + user_hexid, username, crackme_hexid, submitted_flag, result): + """Persist one flag-validation attempt and return the inserted document.""" + if not check_connection(): + raise ErrUnavailable("Database is unavailable") + + obj_id = ObjectId() + submission = { + '_id': obj_id, + 'hexid': str(obj_id), + 'user_hexid': user_hexid, + 'username': username, + 'crackme_hexid': crackme_hexid, + 'submitted_flag': submitted_flag, + 'result': result, + 'created_at': datetime.now(timezone.utc), + } + get_collection('flag_submission').insert_one(submission) + return submission + + +def flag_submissions_by_crackme(crackme_hexid, limit=100): + """Return the newest audit entries for a crackme.""" + if not check_connection(): + raise ErrUnavailable("Database is unavailable") + + return list( + get_collection('flag_submission') + .find({'crackme_hexid': crackme_hexid}) + .sort('created_at', -1) + .limit(limit) + ) + + +def count_flag_submissions_by_crackme(crackme_hexid): + """Return the number of attempted flag submissions for a crackme.""" + if not check_connection(): + raise ErrUnavailable("Database is unavailable") + + return get_collection('flag_submission').count_documents({ + 'crackme_hexid': crackme_hexid, + }) diff --git a/app/models/solve.py b/app/models/solve.py new file mode 100644 index 0000000..51f7773 --- /dev/null +++ b/app/models/solve.py @@ -0,0 +1,115 @@ +"""Solve model - records of users who submitted a crackme's correct flag. + +A solve is the unit the point system is built on: one record per (user, +crackme) pair, carrying the points awarded at the moment it was earned. + +Solves are keyed by the user's immutable hexid rather than their username. +Usernames are display data that can change; a score that silently zeroed out +when someone renamed themselves would be worse than no score at all. +""" + +from datetime import datetime, timezone + +from bson import ObjectId + +from app.models.errors import ErrUnavailable +from app.services.database import get_collection, check_connection + + +def solve_by_user_and_crackme(user_hexid, crackme_hexid): + """Return the user's solve of a crackme, or None if they haven't solved it.""" + if not check_connection(): + raise ErrUnavailable("Database is unavailable") + + return get_collection('solve').find_one({ + 'user_hexid': user_hexid, + 'crackme_hexid': crackme_hexid, + }) + + +def solve_create(user_hexid, crackme_hexid, points, difficulty): + """Record a solve and return it. + + Args: + user_hexid: The solver's immutable hexid. + crackme_hexid: The solved crackme's hexid. + points: Points awarded, snapshotted so a later change to the scoring + formula doesn't retroactively re-price solves already earned. + difficulty: The difficulty level those points were priced at. + + Returns: + The inserted solve document, or the existing one if the user had + already solved this crackme (double-submits are a no-op, never a + second award). + """ + if not check_connection(): + raise ErrUnavailable("Database is unavailable") + + collection = get_collection('solve') + existing = collection.find_one({ + 'user_hexid': user_hexid, + 'crackme_hexid': crackme_hexid, + }) + if existing: + return existing + + obj_id = ObjectId() + solve = { + '_id': obj_id, + 'hexid': str(obj_id), + 'user_hexid': user_hexid, + 'crackme_hexid': crackme_hexid, + 'created_at': datetime.now(timezone.utc), + 'points': int(points), + 'difficulty': float(difficulty), + } + collection.insert_one(solve) + return solve + + +def solves_by_user(user_hexid): + """Get a user's solves, newest first.""" + if not check_connection(): + raise ErrUnavailable("Database is unavailable") + + solves = list(get_collection('solve').find({'user_hexid': user_hexid})) + solves.sort(key=lambda s: s.get('created_at') or s['_id'].generation_time, + reverse=True) + return solves + + +def user_score(user_hexid): + """Return a user's total score: the sum of the points on their solves. + + Summed from the solve records rather than kept as a counter on the user + document, so the score can never drift out of step with the solves it is + supposed to represent (a deleted crackme takes its points with it). + """ + if not check_connection(): + raise ErrUnavailable("Database is unavailable") + + solves = get_collection('solve').find({'user_hexid': user_hexid}, + {'points': 1}) + return sum(solve.get('points', 0) for solve in solves) + + +def count_solves_by_crackme(crackme_hexid): + """Count how many users have solved a crackme.""" + if not check_connection(): + raise ErrUnavailable("Database is unavailable") + + return get_collection('solve').count_documents( + {'crackme_hexid': crackme_hexid} + ) + + +def solves_by_crackme(crackme_hexid): + """Return a crackme's solves, oldest first.""" + if not check_connection(): + raise ErrUnavailable("Database is unavailable") + + return list( + get_collection('solve') + .find({'crackme_hexid': crackme_hexid}) + .sort('created_at', 1) + ) diff --git a/app/models/user.py b/app/models/user.py index e84350f..c502e80 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -109,6 +109,27 @@ def all_users_visible(): return list(collection.find({'visible': True})) +def users_by_hexids(hexids): + """Return visible users keyed by immutable hexid.""" + if not check_connection(): + raise ErrUnavailable("Database is unavailable") + + hexids = list(hexids) + if not hexids: + return {} + return { + user.get('hexid') or str(user['_id']): user + for user in get_collection('user').find({ + '$or': [ + {'hexid': {'$in': hexids}}, + {'_id': {'$in': [ObjectId(value) for value in hexids + if ObjectId.is_valid(value)]}}, + ], + 'visible': True, + }) + } + + def user_create(name, email, password): """Create a new user. diff --git a/app/services/archive.py b/app/services/archive.py index 8caadee..ad7d784 100644 --- a/app/services/archive.py +++ b/app/services/archive.py @@ -169,6 +169,11 @@ def is_single_file_archive(file_data: bytes) -> bool: return file_count == 1 +def is_zip_archive(file_data: bytes) -> bool: + """Return whether ``file_data`` is a readable ZIP archive.""" + return get_archive_file_count(file_data) is not None + + def is_archive_password_protected(file_data: bytes) -> bool: """Check if an archive file is password-protected. diff --git a/app/services/discord.py b/app/services/discord.py index acb4ead..f6cd15f 100644 --- a/app/services/discord.py +++ b/app/services/discord.py @@ -192,6 +192,79 @@ 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, + crackme_hexid: str, points: int) -> bool: + """Announce a successful auto-validated solve in the public channel.""" + timestamp = ( + datetime.datetime.utcnow() + .replace(tzinfo=timezone.utc) + .isoformat(timespec='milliseconds') + .replace('+00:00', 'Z') + ) + base_url = get_base_url() + embed = { + "title": "Crackme Solved", + "description": "A player submitted the correct flag", + "color": 65280, + "fields": [ + { + "name": "Challenge", + "value": f"[{crackme_name}]({base_url}/crackme/{crackme_hexid})", + "inline": True, + }, + { + "name": "Player", + "value": f"[{username}]({base_url}/user/{username})", + "inline": True, + }, + {"name": "Points", "value": str(points), "inline": True}, + ], + "footer": {"text": "CrackMes.One"}, + "timestamp": timestamp, + } + if not is_enabled(): + return True + 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/app/services/flag.py b/app/services/flag.py new file mode 100644 index 0000000..7d9e60d --- /dev/null +++ b/app/services/flag.py @@ -0,0 +1,55 @@ +"""Flag format and comparison for auto-validated crackmes. + +Authors of an auto-validated crackme give us the correct flag once, at upload +time. It is stored in cleartext so reviewers can read it: verifying that a +submission really is solvable, and fixing a mistyped flag afterwards, both need +the actual value, and a hash would leave a wrong flag undetectable until users +started failing on it. + +Cleartext storage means the flag must never leave the reviewer tool: the public +crackme page renders a fixed set of fields and the flag is not among them (see +``crackme_view``), and submissions are only ever compared against it here. +""" + +import hmac +import re + +# Standardised flag format, per issue #127: a CMO prefix and a brace-delimited +# body, so a flag is always a single unambiguous token that authors can embed in +# a binary and users can copy-paste. +FLAG_PREFIX = 'CMO' +FLAG_BODY_MAX = 56 +# Printable ASCII (0x21-0x7e) minus the braces, which keeps the closing brace +# unambiguous. Keeping a flag a single whitespace-free ASCII token means neither +# copy-pasting it out of a terminal nor re-encoding it can silently change it. +FLAG_PATTERN = re.compile( + r'^%s\{[\x21-\x7a\x7c\x7e]{1,%d}\}$' % (FLAG_PREFIX, FLAG_BODY_MAX) +) + +FLAG_FORMAT_HINT = f'Flags look like {FLAG_PREFIX}{{...}}' + + +def normalize_flag(flag): + """Return a submitted flag with surrounding whitespace removed. + + Users copy flags out of terminals, so leading/trailing whitespace is noise + rather than a wrong answer. Inner characters are left untouched -- they are + part of the flag. + """ + return (flag or '').strip() + + +def is_valid_flag_format(flag): + """Return True if the flag matches the standardised CMO{...} format.""" + return bool(FLAG_PATTERN.match(flag or '')) + + +def flags_match(stored_flag, submitted_flag): + """Return True if a submitted flag matches the crackme's stored one. + + Compared in constant time so the response can't be used to recover the flag + character by character. + """ + if not stored_flag or not submitted_flag: + return False + return hmac.compare_digest(stored_flag, submitted_flag) diff --git a/app/services/limiter.py b/app/services/limiter.py index 2f2a340..d7b536a 100644 --- a/app/services/limiter.py +++ b/app/services/limiter.py @@ -15,6 +15,7 @@ | POST /upload/crackme | 10 per day | Username | Prevent submission spam | | POST /upload/solution | 20 per day | Username | Prevent submission spam | | POST /comment | 30 per hour | Username | Prevent comment spam | +| POST /crackme/../solve | 5/min, 20/hr | Username | Prevent flag brute-forcing | +-------------------------+----------------+-------------+----------------------------------+ Configuration @@ -40,6 +41,10 @@ limiter = None limiter_config = {} +DEFAULT_LIMITS = { + 'FlagSubmissions': '5 per minute; 20 per hour', +} + def init_limiter(app, config): """Initialize rate limiter with configuration. @@ -51,7 +56,13 @@ def init_limiter(app, config): - StorageUri: str - Storage backend URI (default: memory://) """ global limiter, limiter_config - limiter_config = config + # Abuse protection is on by default. Deployments and tests can explicitly + # disable it, select another storage backend, or override named limits. + limiter_config = { + 'Enabled': True, + 'StorageUri': 'memory://', + **(config or {}), + } if not is_enabled(): # Create a no-op limiter that doesn't actually limit @@ -83,6 +94,11 @@ def get_limiter(): return limiter +def configured_limit(name): + """Return a named route limit, falling back to its built-in default.""" + return limiter_config.get('Limits', {}).get(name, DEFAULT_LIMITS[name]) + + def limit(*args, **kwargs): """Decorator for rate limiting routes. diff --git a/app/services/points.py b/app/services/points.py new file mode 100644 index 0000000..668fa37 --- /dev/null +++ b/app/services/points.py @@ -0,0 +1,43 @@ +"""Scoring rules for solved crackmes. + +PROVISIONAL: the point system is still being designed (issue #127 lists first +blood on old crackmes, writeup points, author-funded bounties and decay-by-solve- +count as candidates). Only the base "solve an auto-validated crackme" award is +implemented so far, and the numbers here are expected to change. + +Everything about the formula lives in this module so a later change is one edit. +Awards are snapshotted onto the solve record at solve time (see +:mod:`app.models.solve`), so tuning the formula re-prices future solves without +silently rewriting everyone's score history. +""" + +# Points per difficulty level: a level 3 crackme is worth 300. +POINTS_PER_DIFFICULTY = 100 + +MIN_DIFFICULTY = 1 +MAX_DIFFICULTY = 6 + + +def solve_difficulty(crackme): + """Return the difficulty level a solve of ``crackme`` is priced at. + + Prefers the ``official_difficulty`` a reviewer assigned when approving the + crackme -- issue #127 wants that number fixed, immune to the community + difficulty rating drifting after the fact. Crackmes approved before the + reviewer form existed have no official difficulty, so those fall back to the + community rating, rounded and clamped into the 1-6 scale. + """ + official = crackme.get('official_difficulty') + if official: + return _clamp(float(official)) + + return _clamp(round(crackme.get('difficulty') or 0)) + + +def points_for_solve(crackme): + """Return the points awarded for solving ``crackme``.""" + return round(solve_difficulty(crackme) * POINTS_PER_DIFFICULTY) + + +def _clamp(difficulty): + return max(MIN_DIFFICULTY, min(MAX_DIFFICULTY, difficulty)) diff --git a/config/config.json.example b/config/config.json.example index 85fa7bb..dd3c487 100644 --- a/config/config.json.example +++ b/config/config.json.example @@ -18,7 +18,10 @@ }, "RateLimiter": { "Enabled": true, - "StorageUri": "memory://" + "StorageUri": "memory://", + "Limits": { + "FlagSubmissions": "5 per minute; 20 per hour" + } }, "Discord": { "Enabled": false, diff --git a/review/routes.py b/review/routes.py index eab0686..c0784b5 100644 --- a/review/routes.py +++ b/review/routes.py @@ -27,6 +27,7 @@ import requests from rustyzipper import compress_file, EncryptionMethod from bson.objectid import ObjectId +from werkzeug.utils import secure_filename from review.logger import log_reviewer_operation from review.auth import ( @@ -45,6 +46,15 @@ from app.services.crypto import get_obfuscation_salt from app.services.view import is_valid_hexid from app.services.labels import get_label_groups, normalize_labels +from app.services.crackme_fields import ( + ARCH_CHOICES, LANG_CHOICES, PLATFORM_CHOICES, +) +from app.services.flag import ( + FLAG_FORMAT_HINT, is_valid_flag_format, normalize_flag +) +from app.services.archive import ( + is_archive_password_protected, is_unsupported_archive +) from app.models.label_request import ( label_requests_pending, count_pending_label_requests, label_request_by_hexid, label_request_set_status, STATUS_APPROVED, STATUS_REJECTED, @@ -171,6 +181,27 @@ def get_static_dir(item_type): return os.path.join(CRACKMESONE_DIR, 'static', item_type) +def get_source_dir(): + """ + Get the directory holding private source archives. + + Auto-validated crackmes ship with a source archive that only reviewers may + read, so it lives outside static/ and is never linked from the public site. + + Returns: + Absolute path to the private source archive directory + """ + return os.path.join(CRACKMESONE_DIR, 'private', 'crackme_source') + + +def delete_source_archive(hexid): + """Remove a crackme's private source archive, if it has one.""" + try: + os.remove(os.path.join(get_source_dir(), hexid)) + except OSError: + pass + + def find_pending_file(item_type, hexid): """ Find a pending submission file by its hexid. @@ -605,7 +636,14 @@ def get_crackme_details(uuid): "lang": crackme_obj["lang"], "arch": crackme_obj["arch"], "platform": crackme_obj["platform"], - "labels": crackme_obj.get("labels", []) + "labels": crackme_obj.get("labels", []), + # Auto-validation. The flag is reviewer-only data: it reaches this + # template and nowhere else. + "flag": crackme_obj.get("flag"), + "auto_validation": bool(crackme_obj.get("flag")), + "has_source_archive": bool(crackme_obj.get("source_original_filename")), + "difficulty": crackme_obj.get("difficulty", 0), + "official_difficulty": crackme_obj.get("official_difficulty") }, None @@ -641,6 +679,10 @@ def reject_pending_crackme(hexid, reject_reason=None): if os.path.exists(file_path): os.remove(file_path) + # A rejected submission's private source archive has no reason to stay + # on disk. + delete_source_archive(hexid) + # Notify author notif_text = f"Your crackme '{html_escape(crackme['name'])}' has been rejected!" if reject_reason: @@ -933,6 +975,8 @@ def delete_approved_crackme(crackme_uuid): except Exception: pass + delete_source_archive(crackme_uuid) + # Delete crackme document g_crackmesone_db.crackme.delete_one({"_id": ObjectId(crackme_uuid)}) @@ -940,7 +984,8 @@ def delete_approved_crackme(crackme_uuid): f"Cascade deleted: {deleted['solutions']} solutions, " f"{deleted['comments']} comments, " f"{deleted['difficulty_ratings']} difficulty ratings, " - f"{deleted['quality_ratings']} quality ratings\n" + f"{deleted['quality_ratings']} quality ratings, " + f"{deleted['solves']} solves\n" "Crackme deleted" ) @@ -960,7 +1005,9 @@ def _cascade_delete_crackme_data(crackme_id, crackme_hexid): 'solutions': 0, 'comments': 0, 'difficulty_ratings': 0, - 'quality_ratings': 0 + 'quality_ratings': 0, + 'solves': 0, + 'flag_submissions': 0 } # Delete solutions @@ -988,6 +1035,18 @@ def _cascade_delete_crackme_data(crackme_id, crackme_hexid): }) deleted['quality_ratings'] = result.deleted_count + # Solve records, and with them the points their solvers earned: the crackme + # they were awarded for no longer exists to back them up. + result = g_crackmesone_db.solve.delete_many({ + 'crackme_hexid': crackme_hexid + }) + deleted['solves'] = result.deleted_count + + result = g_crackmesone_db.flag_submission.delete_many({ + 'crackme_hexid': crackme_hexid + }) + deleted['flag_submissions'] = result.deleted_count + return deleted @@ -1057,6 +1116,8 @@ def preview_user_deletion(user_email): 'email': user_email, 'notifications': 0, 'solutions': 0, + 'solves': 0, + 'flag_submissions': 0, 'crackmes': 0, 'crackme_details': [], 'user_comments': 0, @@ -1075,6 +1136,12 @@ def preview_user_deletion(user_email): preview['solutions'] = db.solution.count_documents({ "author": username }) + preview['solves'] = db.solve.count_documents({ + "user_hexid": user.get("hexid") or str(user["_id"]) + }) + preview['flag_submissions'] = db.flag_submission.count_documents({ + "user_hexid": user.get("hexid") or str(user["_id"]) + }) # Count data for each crackme for crackme in db.crackme.find({"author": username}): @@ -1087,6 +1154,7 @@ def preview_user_deletion(user_email): 'hexid': hexid, 'solutions': db.solution.count_documents({"crackmeid": cid}), 'comments': db.comment.count_documents({"crackmehexid": hexid}), + 'solves': db.solve.count_documents({"crackme_hexid": hexid}), 'difficulty_ratings': db.rating_difficulty.count_documents({ "crackmehexid": hexid }), @@ -1164,7 +1232,20 @@ def delete_user_account(user_email, admin_username=None): result = db.notifications.delete_many({"user": username}) deletion_log.append(f"Deleted {result.deleted_count} notifications") - # 2. Delete user's solutions + # 2. Delete the user's solve records (their score goes with the account) + result = db.solve.delete_many({ + "user_hexid": user.get("hexid") or str(user["_id"]) + }) + deletion_log.append(f"Deleted {result.deleted_count} solves by user") + + result = db.flag_submission.delete_many({ + "user_hexid": user.get("hexid") or str(user["_id"]) + }) + deletion_log.append( + f"Deleted {result.deleted_count} flag submissions by user" + ) + + # 2b. Delete user's solutions solution_count = 0 for solution in db.solution.find({"author": username}): delete_approved_solution(str(solution["_id"])) @@ -1399,6 +1480,40 @@ def dashboard(current_user): ) +@reviewer_bp.route('/flagvalidations') +@admin_required +def flagvalidations(current_user): + """Show recent flag submissions and validation results to admins.""" + submissions = list( + g_crackmesone_db.flag_submission + .find({}) + .sort('created_at', -1) + .limit(250) + ) + crackme_ids = { + item.get('crackme_hexid') for item in submissions + if item.get('crackme_hexid') + } + crackme_names = { + item['hexid']: item.get('name', 'Unnamed crackme') + for item in g_crackmesone_db.crackme.find( + {'hexid': {'$in': list(crackme_ids)}}, + {'hexid': 1, 'name': 1} + ) + } + for item in submissions: + item['crackme_name'] = crackme_names.get( + item.get('crackme_hexid'), 'Deleted crackme' + ) + + return render_template( + 'reviewer/flagvalidations.html', + user=current_user['username'], + is_admin=True, + submissions=submissions, + ) + + # ============================================================================= # Route Handlers - Review Pending Submissions # ============================================================================= @@ -1470,40 +1585,46 @@ def viewcrackme(current_user): user=current_user['username'], is_admin=current_user['is_admin'], crackme=crackme, - label_groups=get_label_groups() + label_groups=get_label_groups(), + message=request.args.get('message') ) @reviewer_bp.route('/downloadreview') @token_required def downloadreview(current_user): - """Download a pending submission file for review.""" + """Download a pending submission file, or a crackme's private source, for review.""" download_type = request.args.get("type") uuid = request.args.get("uuid") - if download_type not in ('solution', 'crackme'): + if download_type not in ('solution', 'crackme', 'source'): abort(404) if not is_valid_hexid(uuid): abort(404) uuid = uuid.lower() - tmp_dir = get_tmp_dir(download_type) - file_path = os.path.join(tmp_dir, uuid) + if download_type == 'source': + # Source archives stay reviewer-only for the crackme's whole life, so + # they live in their own private directory rather than tmp/. + file_path = os.path.join(get_source_dir(), uuid) + else: + file_path = os.path.join(get_tmp_dir(download_type), uuid) if not os.path.exists(file_path): abort(404) # Get original filename from database - if download_type == 'crackme': - doc = g_crackmesone_db.crackme.find_one({'hexid': uuid}) - else: + if download_type == 'solution': doc = g_crackmesone_db.solution.find_one({'hexid': uuid}) + else: + doc = g_crackmesone_db.crackme.find_one({'hexid': uuid}) if not doc: print(f"Warning: Orphaned {download_type} file {uuid} exists on disk but not in database") - original_filename = (doc.get('original_filename') if doc else None) or uuid + filename_field = 'source_original_filename' if download_type == 'source' else 'original_filename' + original_filename = (doc.get(filename_field) if doc else None) or uuid return send_file( file_path, @@ -1657,6 +1778,81 @@ def approvecrackme(current_user): message="Crackme file not found" )) + crackme_obj = g_crackmesone_db.crackme.find_one({ + 'hexid': crackme_uuid.lower(), 'visible': False, + }) + if not crackme_obj: + return redirect(url_for( + 'reviewer.reviewcrackme', message="Pending crackme not found" + )) + + info = request.form.get('info', crackme_obj.get('info', '')).strip() + lang = request.form.get('lang', crackme_obj.get('lang', '')) + arch = request.form.get('arch', crackme_obj.get('arch', '')) + platform = request.form.get('platform', crackme_obj.get('platform', '')) + if not info: + return redirect(url_for( + 'reviewer.viewcrackme', crackme_uuid=crackme_uuid, + message="Description is required" + )) + valid_classification = ( + (lang in LANG_CHOICES or lang == crackme_obj.get('lang')) and + (arch in ARCH_CHOICES or arch == crackme_obj.get('arch')) and + (platform in PLATFORM_CHOICES or platform == crackme_obj.get('platform')) + ) + if not valid_classification: + return redirect(url_for( + 'reviewer.viewcrackme', crackme_uuid=crackme_uuid, + message="Invalid language, architecture, or platform" + )) + + updates = { + 'info': info, + 'lang': lang, + 'arch': arch, + 'platform': platform, + 'labels': ( + normalize_labels(request.form.getlist('labels')) + if request.form.get('labels_submitted') == '1' + else crackme_obj.get('labels', []) + ), + } + + if crackme_obj.get('flag'): + flag = normalize_flag(request.form.get('flag', crackme_obj.get('flag', ''))) + if not is_valid_flag_format(flag): + return redirect(url_for( + 'reviewer.viewcrackme', crackme_uuid=crackme_uuid, + message=f"Invalid flag format. {FLAG_FORMAT_HINT}" + )) + updates['flag'] = flag + + # Reviewers may adjust the author's proposed reward before approval. The + # database keeps this as points / 100 in the official difficulty field. + official_points = request.form.get('official_points', '').strip() + if (crackme_obj.get('flag') and 'official_points' not in request.form + and crackme_obj.get('official_difficulty')): + official_points = str(round(crackme_obj['official_difficulty'] * 100)) + if crackme_obj.get('flag') or official_points: + try: + official_points = int(official_points) + except ValueError: + official_points = 0 + if not 100 <= official_points <= 600: + return redirect(url_for( + 'reviewer.viewcrackme', crackme_uuid=crackme_uuid, + message="Points must be a whole number from 100 to 600" + )) + updates['official_difficulty'] = official_points / 100 + + g_crackmesone_db.crackme.update_one( + {'_id': crackme_obj['_id']}, {'$set': updates} + ) + log_reviewer_operation( + "update_pending_crackme", current_user['username'], + {"crackme_uuid": crackme_uuid, "fields": sorted(updates)}, True + ) + success, message = approve_pending_crackme(crackme_file) log_reviewer_operation( @@ -2058,9 +2254,13 @@ def deletecrackme(current_user): @reviewer_bp.route('/editcrackme', methods=['GET', 'POST']) @admin_required def editcrackme(current_user): - """Edit an approved crackme (admin only). + """Edit every field of a crackme, approved or still pending (admin only). - Allows editing crackme metadata and optionally replacing the file. + This is the one place a crackme can be corrected after the fact: metadata, + labels, the binary, and everything auto-validation depends on -- the flag, + the private source archive and the official difficulty that fixes what a + solve is worth. Difficulty in particular used to be settable only while + approving, which left a typo unfixable once the crackme was live. """ message = None error = None @@ -2068,118 +2268,14 @@ def editcrackme(current_user): crackme_uuid = request.args.get('crackme_uuid') or request.form.get('crackme_uuid') - if request.method == 'POST' and crackme_uuid: + if request.method == 'POST' and crackme_uuid and ObjectId.is_valid(crackme_uuid): validate_csrf_token() + crackme_obj = g_crackmesone_db.crackme.find_one({"_id": ObjectId(crackme_uuid)}) - # Get form data (name is not editable) - info = request.form.get('info', '').strip() - lang = request.form.get('lang', '') - arch = request.form.get('arch', '') - platform = request.form.get('platform', '') - labels = normalize_labels(request.form.getlist('labels')) - notify_author = request.form.get('notify_author') == 'on' - - if True: - # Get current crackme - crackme_obj = g_crackmesone_db.crackme.find_one({ - "_id": ObjectId(crackme_uuid) - }) - - if not crackme_obj: - error = "Crackme not found" - else: - # Track changes (name is not editable) - changes = [] - if crackme_obj.get('info') != info: - changes.append("description updated") - if crackme_obj.get('lang') != lang: - changes.append(f"language: '{crackme_obj.get('lang')}' -> '{lang}'") - if crackme_obj.get('arch') != arch: - changes.append(f"arch: '{crackme_obj.get('arch')}' -> '{arch}'") - if crackme_obj.get('platform') != platform: - changes.append(f"platform: '{crackme_obj.get('platform')}' -> '{platform}'") - if sorted(crackme_obj.get('labels', [])) != sorted(labels): - changes.append(f"labels: {crackme_obj.get('labels', [])} -> {labels}") - - # Update the crackme (name excluded) - g_crackmesone_db.crackme.update_one( - {"_id": ObjectId(crackme_uuid)}, - {"$set": { - "info": info, - "lang": lang, - "arch": arch, - "platform": platform, - "labels": labels - }} - ) - - # Handle file replacement - file_replaced = False - if 'file' in request.files: - file = request.files['file'] - if file.filename: - file_data = file.read() - if len(file_data) > 0: - # Save to temp location - temp_path = os.path.join( - CRACKMESONE_DIR, 'tmp', - f"replace_{crackme_uuid}_{file.filename}" - ) - os.makedirs(os.path.dirname(temp_path), exist_ok=True) - - with open(temp_path, 'wb') as f: - f.write(file_data) - - # Create password-protected zip - dest_path = os.path.join( - get_static_dir('crackme'), - crackme_obj['hexid'] - ) - - # Remove old zip first - old_zip = dest_path + ".zip" - if os.path.exists(old_zip): - os.remove(old_zip) - - success, zip_error = create_password_protected_zip( - temp_path, dest_path, file.filename - ) - - if success: - file_replaced = True - changes.append("file replaced") - else: - error = f"Failed to replace file: {zip_error}" - - if changes: - # Log the operation - log_reviewer_operation( - "edit_crackme_admin", current_user['username'], - { - "crackme_uuid": crackme_uuid, - "crackme_name": crackme_obj.get('name'), - "changes": changes - }, - True - ) - - # Notify author if requested - if notify_author and not error: - try: - change_summary = ", ".join(changes[:3]) - if len(changes) > 3: - change_summary += f" and {len(changes) - 3} more" - send_user_notification( - crackme_obj['author'], - f"Your crackme '{html_escape(crackme_obj.get('name'))}' has been updated by an admin: {html_escape(change_summary)}" - ) - except Exception as e: - print(f"Notification error: {e}") - - if not error: - message = f"Crackme '{crackme_obj.get('name')}' updated successfully" - else: - message = "No changes were made" + if not crackme_obj: + error = "Crackme not found" + else: + error, message = _apply_crackme_edit(current_user, crackme_obj, request) # Load crackme for display if crackme_uuid and ObjectId.is_valid(crackme_uuid): @@ -2195,9 +2291,15 @@ def editcrackme(current_user): "arch": crackme_obj.get('arch', ''), "platform": crackme_obj.get('platform', ''), "author": crackme_obj.get('author', ''), - "labels": crackme_obj.get('labels', []) + "labels": crackme_obj.get('labels', []), + "visible": crackme_obj.get('visible', False), + "difficulty": crackme_obj.get('difficulty', 0), + "official_difficulty": crackme_obj.get('official_difficulty'), + # Reviewer-only fields; no public view renders either of these. + "flag": crackme_obj.get('flag') or '', + "source_original_filename": crackme_obj.get('source_original_filename') or '' } - else: + elif not error: error = "Crackme not found" return render_template( @@ -2207,10 +2309,186 @@ def editcrackme(current_user): crackme=crackme, message=message, error=error, + flag_format_hint=FLAG_FORMAT_HINT, label_groups=get_label_groups() ) +def _apply_crackme_edit(current_user, crackme_obj, request): + """Apply a submitted crackme edit. + + Returns: + Tuple of (error, message), either of which may be None. + """ + crackme_uuid = crackme_obj['hexid'] + + # Metadata (the crackme's name is not edited here) + updates = { + 'info': request.form.get('info', '').strip(), + 'lang': request.form.get('lang', ''), + 'arch': request.form.get('arch', ''), + 'platform': request.form.get('platform', ''), + 'labels': normalize_labels(request.form.getlist('labels')), + } + notify_author = request.form.get('notify_author') == 'on' + + # Official points are stored as points / 100 in official_difficulty. An + # empty value clears the override and falls back to the community rating. + official_points = request.form.get('official_points', '').strip() + if official_points: + try: + official_points = int(official_points) + except ValueError: + return "Points must be a whole number from 100 to 600", None + if not 100 <= official_points <= 600: + return "Points must be a whole number from 100 to 600", None + updates['official_difficulty'] = official_points / 100 + else: + updates['official_difficulty'] = None + + # Flag. Clearing it turns auto-validation off; existing solves and the + # points they carry are left alone, since they were earned fairly. + if request.form.get('remove_flag') == 'on': + updates['flag'] = None + else: + flag = normalize_flag(request.form.get('flag', '')) + if flag and not is_valid_flag_format(flag): + return f"Invalid flag format. {FLAG_FORMAT_HINT}", None + updates['flag'] = flag or None + + # Replacement source archive (reviewer-only, never published) + source_data = None + source_file = request.files.get('source_file') + if source_file and source_file.filename: + source_data = source_file.read() + if is_unsupported_archive(source_data): + return "Source archive must be a ZIP (RAR/tar are not supported)", None + if is_archive_password_protected(source_data): + return "Source archive must not be password-protected", None + updates['source_original_filename'] = secure_filename(source_file.filename) or "source" + + changes = _describe_crackme_changes(crackme_obj, updates) + if source_data is not None: + changes.append("source archive replaced") + + g_crackmesone_db.crackme.update_one( + {"_id": crackme_obj['_id']}, {"$set": updates} + ) + + if source_data is not None: + try: + os.makedirs(get_source_dir(), exist_ok=True) + with open(os.path.join(get_source_dir(), crackme_uuid), 'wb') as f: + f.write(source_data) + except OSError as e: + return f"Failed to save source archive: {e}", None + + error = None + replaced, replace_error = _replace_crackme_file(crackme_obj, request) + if replaced: + changes.append("file replaced") + if replace_error: + error = replace_error + + if not changes: + return error, "No changes were made" + + log_reviewer_operation( + "edit_crackme_admin", current_user['username'], + { + "crackme_uuid": crackme_uuid, + "crackme_name": crackme_obj.get('name'), + "changes": changes + }, + True + ) + + if notify_author and not error: + try: + change_summary = ", ".join(changes[:3]) + if len(changes) > 3: + change_summary += f" and {len(changes) - 3} more" + send_user_notification( + crackme_obj['author'], + f"Your crackme '{html_escape(crackme_obj.get('name'))}' has been updated by an admin: {html_escape(change_summary)}" + ) + except Exception as e: + print(f"Notification error: {e}") + + if error: + return error, None + return None, f"Crackme '{crackme_obj.get('name')}' updated successfully" + + +def _describe_crackme_changes(crackme_obj, updates): + """Summarise an edit for the operation log and the author's notification. + + The flag is reported as changed but never quoted: the log is mirrored to a + Discord channel, which is one more place a live flag doesn't need to be. + """ + labels = ('description', 'language', 'arch', 'platform', 'labels', + 'official difficulty') + fields = ('info', 'lang', 'arch', 'platform', 'labels', 'official_difficulty') + + changes = [] + for label, field in zip(labels, fields): + old, new = crackme_obj.get(field), updates[field] + if field == 'labels': + if sorted(old or []) != sorted(new): + changes.append(f"labels: {old or []} -> {new}") + elif old != new: + if field == 'info': + changes.append("description updated") + else: + changes.append(f"{label}: '{old}' -> '{new}'") + + old_flag, new_flag = crackme_obj.get('flag'), updates['flag'] + if old_flag != new_flag: + if not new_flag: + changes.append("flag removed (auto-validation off)") + elif not old_flag: + changes.append("flag set (auto-validation on)") + else: + changes.append("flag changed") + + return changes + + +def _replace_crackme_file(crackme_obj, request): + """Replace the downloadable crackme archive, if a new file was uploaded. + + Returns: + Tuple of (replaced: bool, error: str or None). + """ + file = request.files.get('file') + if not file or not file.filename: + return False, None + + file_data = file.read() + if not file_data: + return False, None + + temp_path = os.path.join( + CRACKMESONE_DIR, 'tmp', + f"replace_{crackme_obj['hexid']}_{file.filename}" + ) + os.makedirs(os.path.dirname(temp_path), exist_ok=True) + with open(temp_path, 'wb') as f: + f.write(file_data) + + dest_path = os.path.join(get_static_dir('crackme'), crackme_obj['hexid']) + old_zip = dest_path + ".zip" + if os.path.exists(old_zip): + os.remove(old_zip) + + success, zip_error = create_password_protected_zip( + temp_path, dest_path, file.filename + ) + if not success: + return False, f"Failed to replace file: {zip_error}" + return True, None + + @reviewer_bp.route('/delcomment', methods=['GET', 'POST']) @admin_required def delcomment(current_user): @@ -2854,6 +3132,10 @@ def create_site_archive_background(requesting_user): set_archive_status('running', step='Exporting crackmes database...') crackmes = list(db.crackme.find({})) for c in crackmes: + # Flags are reviewer-only secrets and are not part of the public + # site archive. Omit the key entirely rather than exporting a + # placeholder value. + c.pop('flag', None) c['_id'] = str(c['_id']) with open(os.path.join(archive_folder, 'database', 'crackmes.json'), 'w') as f: json.dump(crackmes, f, indent=2, default=str) diff --git a/review/templates/reviewer/_deletion_preview.html b/review/templates/reviewer/_deletion_preview.html index 8e4e67c..5c0f57a 100644 --- a/review/templates/reviewer/_deletion_preview.html +++ b/review/templates/reviewer/_deletion_preview.html @@ -59,6 +59,14 @@ {{ preview.solutions }} Solutions posted by user +
+ {{ preview.solves }} Solves by user (the points they earned go too) +
+ +
+ {{ preview.flag_submissions }} Flag Submissions by user +
+
{{ preview.user_comments }} Comments by user on other crackmes
@@ -82,6 +90,7 @@
-> {{ crackme.solutions }} solutions will be cascade deleted
-> {{ crackme.comments }} comments will be cascade deleted
+ -> {{ crackme.solves }} solves by other users will be cascade deleted (they lose those points)
-> {{ crackme.difficulty_ratings }} difficulty ratings will be cascade deleted
-> {{ crackme.quality_ratings }} quality ratings will be cascade deleted
@@ -99,6 +108,7 @@
  • {{ preview.solutions + preview.total_solutions_on_user_crackmes }} solutions ({{ preview.solutions }} by user + {{ preview.total_solutions_on_user_crackmes }} on user's crackmes)
  • {{ preview.total_comments }} comments ({{ preview.user_comments }} by user + {{ preview.total_comments - preview.user_comments }} on user's crackmes)
  • {{ preview.crackmes }} crackmes
  • +
  • {{ preview.solves }} solves by user
  • Difficulty/quality ratings will be recalculated for affected crackmes
  • diff --git a/review/templates/reviewer/dashboard.html b/review/templates/reviewer/dashboard.html index a3ce053..07eecab 100644 --- a/review/templates/reviewer/dashboard.html +++ b/review/templates/reviewer/dashboard.html @@ -31,6 +31,7 @@

    Welcome, {{ user }}!{% if is_admin %} (Admin){% endif %}

    Review label requests Account deletion requests {% if is_admin %} + Review flag validations Delete solution Delete crackme Edit crackme @@ -74,6 +75,7 @@

    {{ acctdel_cnt }}


    + diff --git a/review/templates/reviewer/editcrackme.html b/review/templates/reviewer/editcrackme.html index 0919b9e..88b71f9 100644 --- a/review/templates/reviewer/editcrackme.html +++ b/review/templates/reviewer/editcrackme.html @@ -33,7 +33,12 @@

    Welcome, {{ user }}!{% if is_admin %} (Admin){% endif %}

    {% if crackme %}

    Edit crackme: "{{ crackme.name }}" by {{ crackme.author }}

    + {% if crackme.visible %}

    View on site: {{ crackme.name }}

    + {% else %} +

    Still pending review — not visible on the site yet. + Review it.

    + {% endif %}
    @@ -46,51 +51,46 @@

    Edit crackme: "{{ crackme.name }}" by {{ crackme.author }}

    + {# Option lists come from app/services/crackme_fields.py (injected app-wide), + so the reviewer form can't drift from the upload form. #}
    +
    + + +

    + Whole numbers from 100 to 600. Stored internally as points ÷ 100; already-earned + points are not re-priced. +

    +
    +
    @@ -105,6 +105,35 @@

    Edit crackme: "{{ crackme.name }}" by {{ crackme.author }}

    +
    + + +

    + The flag solvers submit for points. Leave empty (or tick below) and this crackme accepts no + flag submissions at all. Setting one on a crackme that had none turns auto-validation on. + Existing solves keep the points they earned either way. +

    + +
    + +
    + + {% if crackme.source_original_filename %} +

    + Current: {{ crackme.source_original_filename }} + — reviewers only, never published. +

    + {% else %} +

    No source archive on file.

    + {% endif %} + +

    Upload a zip to replace it. Leave empty to keep the current one.

    +
    +
    diff --git a/review/templates/reviewer/flagvalidations.html b/review/templates/reviewer/flagvalidations.html new file mode 100644 index 0000000..f5fc125 --- /dev/null +++ b/review/templates/reviewer/flagvalidations.html @@ -0,0 +1,53 @@ + + + + Review flag validations + + + + + + + + +
    +
    + {% if submissions %} + + + + + + {% for submission in submissions %} + + + + + + + + {% endfor %} + +
    UserCrackmeSubmitted flagResultSubmitted
    {{ submission.username }} + {% if submission.crackme_name != 'Deleted crackme' %} + {{ submission.crackme_name }} + {% else %} + {{ submission.crackme_name }} + {% endif %} + {{ submission.submitted_flag }}{{ submission.result|replace('_', ' ')|title }}{{ submission.created_at }}
    + {% else %} +

    No flag submissions have been recorded.

    + {% endif %} +
    +
    + + diff --git a/review/templates/reviewer/viewcrackme.html b/review/templates/reviewer/viewcrackme.html index 02e047a..7bb416a 100644 --- a/review/templates/reviewer/viewcrackme.html +++ b/review/templates/reviewer/viewcrackme.html @@ -1,22 +1,16 @@ - - View crackme + Review crackme -
    -

    -

    "{{ crackme.name }}" by {{ crackme.author }}

    -

    +

    "{{ crackme.name }}" by {{ crackme.author }}

    + {% if message %}
    {{ message }}
    {% endif %} + +
    -

    -

    Crackme info

    -

    -

    Language: {{ crackme.lang }}

    -

    Arch: {{ crackme.arch }}

    -

    Platform: {{ crackme.platform }}

    -
    -

    -

    Description

    -

    -

    {{ crackme.info }}

    -
    -

    -

    Download

    -

    - - Download -
    - -

    -

    Labels

    -

    -

    Author-suggested / current labels. Adjust and save before accepting.

    -
    + - + +

    Crackme info

    +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + +
    + + +
    + +
    +

    Download

    +

    Download crackme

    + +
    +

    Auto-validation

    + {% if crackme.auto_validation %} +

    This crackme will accept a flag and award the point value shown above.

    + {% if crackme.has_source_archive %} +

    Download private verification ZIP

    + {% else %} +

    No verification ZIP was uploaded.

    + {% endif %} +
    + + +
    + {% else %} +

    The author did not enable auto-validation.

    + {% endif %} + +
    +

    Labels

    +

    Adjust the author-suggested labels before approval.

    +
    {% set label_input_name = 'labels' %} {% set checked_labels = crackme.labels %} {% include 'partial/labels_checkboxes.html' %}
    -
    - - -
    - -

    -

    Review

    -

    -
    +
    +

    Review

    + - - +
    - @@ -86,38 +112,41 @@

    - +

    - -
    - -
    - -
    - -
    +
    - - -
    - - - -
    +
    + + + +
    + + + + diff --git a/templates/crackme/create.html b/templates/crackme/create.html index 9bfd1c3..b7b6e1a 100644 --- a/templates/crackme/create.html +++ b/templates/crackme/create.html @@ -23,6 +23,11 @@

    Quick Rules

    Read the full crackme submission rules for detailed guidelines.

    + {# ``form`` carries back what the user typed when a submission is rejected, so a + missing field never costs them the rest of the form. Defaults double as the + first-visit state. #} + {% set form = form|default({}) %} +
    @@ -30,7 +35,7 @@

    Quick Rules

    - +
    @@ -41,7 +46,7 @@

    Quick Rules

    {% set choice_type = 'radio' %} {% set choice_name = 'difficulty' %} {% set choice_options = DIFFICULTY_CHOICES %} - {% set choice_selected = '' %} + {% set choice_selected = form.difficulty|default('') %} {% include 'partial/choice_inputs.html' %}
    @@ -53,7 +58,7 @@

    Quick Rules

    {% set choice_type = 'radio' %} {% set choice_name = 'lang' %} {% set choice_options = LANG_CHOICES %} - {% set choice_selected = '' %} + {% set choice_selected = form.lang|default('') %} {% include 'partial/choice_inputs.html' %}
    @@ -65,7 +70,7 @@

    Quick Rules

    {% set choice_type = 'radio' %} {% set choice_name = 'arch' %} {% set choice_options = ARCH_CHOICES %} - {% set choice_selected = '' %} + {% set choice_selected = form.arch|default('') %} {% include 'partial/choice_inputs.html' %} @@ -77,54 +82,167 @@

    Quick Rules

    {% set choice_type = 'radio' %} {% set choice_name = 'platform' %} {% set choice_options = PLATFORM_CHOICES %} - {% set choice_selected = '' %} + {% set choice_selected = form.platform|default('') %} {% include 'partial/choice_inputs.html' %} +
    +
    + +
    +
    + +
    +
    - +
    -

    - Select the anti-analysis / obfuscation techniques your crackme uses. Please label every technique - that applies; if none apply, it's fine to leave these empty. Pick a broad category and, where it - applies, the specific technique underneath — ticking a technique ticks its category - automatically. Reviewers may adjust these later. -

    -
    - {% set label_input_name = 'labels' %} - {% set checked_labels = [] %} - {% include 'partial/labels_checkboxes.html' %} -
    +
    -
    - +
    +
    -
    - +
    + +
    - +
    - +

    + Select all anti-analysis and obfuscation techniques used. Specific techniques automatically select + their category, and reviewers may adjust labels. Labels on active auto-validated crackmes remain + hidden until scoring ends. +

    +
    + {% set label_input_name = 'labels' %} + {% set checked_labels = form.labels|default([]) %} + {% include 'partial/labels_checkboxes.html' %} +
    {% if RECAPTCHA_SITEKEY %}




    {% endif %} - +
    + {% include 'partial/footer.html' %} {% endblock %} diff --git a/templates/crackme/read.html b/templates/crackme/read.html index a976217..0464f7b 100644 --- a/templates/crackme/read.html +++ b/templates/crackme/read.html @@ -159,14 +159,18 @@

    {{ username }}'s {{ name }}

    {{ platform }}

    + {% if auto_validation %} +

    Points:
    {{ solve_points }}

    + {% else %}

    Difficulty:
    {{ difficulty }} {% if AuthLevel == "auth" %} Rate!

    {% endif %} + {% endif %}

    Quality:
    {{ quality }} - {% if AuthLevel == "auth" %} + {% if AuthLevel == "auth" and (not auto_validation or user_solve) %} Rate!

    {% endif %}
    @@ -181,12 +185,19 @@

    {{ username }}'s {{ name }}

    Size:
    {{ size|FILESIZE }}

    + {% if auto_validation %} +

    Solves:
    {{ nbsolves }}

    + {% else %}

    Writeups:
    {{ nbsolutions }}

    + {% endif %}
    + {% if auto_validation %} +

    Attempts:
    {{ nbattempts }}

    + {% else %}

    Comments:
    {{ nbcomments }}

    + {% endif %}
    -
    @@ -197,6 +208,31 @@

    {{ username }}'s {{ name }}

    + {% if auto_validation %} +
    +

    Flag

    + {% if user_solve %} +

    + Solved! You cracked this on {{ user_solve.created_at|PRETTYTIME }} for {{ user_solve.points }} points. +

    + {% elif usersess == username %} +

    This is your crackme — you can't submit its flag.

    + {% elif AuthLevel == "auth" %} +
    + +
    + + +
    +
    + {% else %} +

    Log in to submit the flag.

    + {% endif %} +
    +
    + {% endif %} + + {% if not auto_validation %}

    Labels @@ -229,18 +265,53 @@

    {{ username }}'s {{ name }}

    + {% endif %}
    + {% if auto_validation %} +
    + {% if solves %} + + + + + + {% for item in solves %} + + + + + + {% endfor %} + +
    SolverSolvedPoints
    + {% if item.username != 'Deleted user' %} + {{ item.username }} + {% else %} + {{ item.username }} + {% endif %} + {{ item.solve.created_at|PRETTYTIME }}{{ item.solve.points }}
    + {% else %} +

    No one has solved this crackme yet.

    + {% endif %} +
    + {% else %} + {% endif %} + {% if not auto_validation %} + {% endif %} + {% if not auto_validation %} + {% endif %} +{% if not auto_validation or user_solve %} +{% endif %} -{% if AuthLevel == "auth" %} +{% if AuthLevel == "auth" and not auto_validation %}