diff --git a/app/controllers/solution.py b/app/controllers/solution.py index f8708a7..dea52ea 100644 --- a/app/controllers/solution.py +++ b/app/controllers/solution.py @@ -1,42 +1,79 @@ """ -Solution controller - Solution uploading. +Solution controller - Solution uploading and viewing. """ 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, session, abort, current_app from werkzeug.utils import secure_filename import bleach from app.models.crackme import crackme_by_hexid -from app.models.solution import solution_create, solutions_by_user_and_crackme +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 from app.services.recaptcha import verify as verify_recaptcha from app.services.limiter import limit -from app.services.view import FLASH_ERROR, FLASH_SUCCESS +from app.services.view import FLASH_ERROR, is_valid_hexid from app.services.archive import is_archive_password_protected, is_pe_file from app.services.discord import notify_new_solution +from app.services.crypto import get_obfuscation_key_base64, get_obfuscation_salt from app.controllers.decorators import login_required solution_bp = Blueprint('solution', __name__) -# Upload folder for solutions UPLOAD_FOLDER = 'tmp/solution' MAX_FILE_SIZE = 10 * 1024 * 1024 # 10MB +MAX_INFO_LENGTH = 200 +MAX_CONTENT_LENGTH = 50000 +MIN_CONTENT_LENGTH = 200 +MARKDOWN_EXTENSIONS = frozenset({'.md', '.txt', '.markdown'}) -@solution_bp.route('/upload/solution/', methods=['GET']) -@login_required -def upload_solution_get(hexidcrackme): - """Display the solution upload form.""" +def _get_crackme_or_abort(hexid): + """Fetch crackme by hexid, abort with 404/500 on error.""" + if not is_valid_hexid(hexid): + abort(404) try: - crackme = crackme_by_hexid(hexidcrackme) + return crackme_by_hexid(hexid) except ErrNoResult: abort(404) except Exception as e: print(f"Error getting crackme: {e}") abort(500) + +def _get_solution_or_abort(hexid): + """Fetch solution by hexid, abort with 404/500 on error.""" + if not is_valid_hexid(hexid): + abort(404) + try: + return solution_by_hexid(hexid) + except ErrNoResult: + abort(404) + except Exception as e: + print(f"Error getting solution: {e}") + abort(500) + + +def _send_notifications_and_render_success(username, crackme): + """Send notifications and render the success page after solution creation.""" + try: + notification_add(username, f"Your solution for '{html_escape(crackme['name'])}' is waiting approval!") + notify_new_solution(username, crackme['name']) + except Exception as e: + print(f"Notification error: {e}") + + return render_template('submission/success.html', + submission_type='Writeup', + name=crackme['name'], + username=username) + + +@solution_bp.route('/upload/solution/', methods=['GET']) +@login_required +def upload_solution_get(hexidcrackme): + """Display the solution upload form.""" + crackme = _get_crackme_or_abort(hexidcrackme) return render_template('solution/create.html', hexidcrackme=hexidcrackme, username=crackme.get('author', ''), @@ -47,95 +84,153 @@ def upload_solution_get(hexidcrackme): @login_required @limit("20 per day", key_func=lambda: session.get('name')) def upload_solution_post(hexidcrackme): - """Handle solution upload.""" + """Handle solution file upload.""" + crackme = _get_crackme_or_abort(hexidcrackme) username = session.get('name') - - info = bleach.clean(request.form.get('info', '')) + redirect_url = f'/upload/solution/{hexidcrackme}' # Check if user already submitted a solution - try: - solutions_by_user_and_crackme(username, hexidcrackme) + if solution_exists(username, crackme['_id']): flash("You've already submitted a solution to this crackme", FLASH_ERROR) - return redirect(f'/upload/solution/{hexidcrackme}') - except ErrNoResult: - pass # No existing solution, continue + return redirect(redirect_url) - # Validate reCAPTCHA if not verify_recaptcha(request): flash('reCAPTCHA invalid!', FLASH_ERROR) - return redirect(f'/upload/solution/{hexidcrackme}') + return redirect(redirect_url) - # Check for file - if 'file' not in request.files: + # Validate file presence + if 'file' not in request.files or request.files['file'].filename == '': flash('Field missing: file', FLASH_ERROR) - return redirect(f'/upload/solution/{hexidcrackme}') + return redirect(redirect_url) file = request.files['file'] - if file.filename == '': - flash('Field missing: file', FLASH_ERROR) - return redirect(f'/upload/solution/{hexidcrackme}') - # Check file size from header + # Check file size (header and actual) if file.content_length and file.content_length > MAX_FILE_SIZE: flash('This file is too large!', FLASH_ERROR) - return redirect(f'/upload/solution/{hexidcrackme}') + return redirect(redirect_url) - # Read file data try: data = file.read() - if len(data) > MAX_FILE_SIZE: - flash('This file is too large!', FLASH_ERROR) - return redirect(f'/upload/solution/{hexidcrackme}') except Exception as e: print(f"Error reading file: {e}") abort(500) + if len(data) > MAX_FILE_SIZE: + flash('This file is too large!', FLASH_ERROR) + return redirect(redirect_url) + # Check for password-protected archives if is_archive_password_protected(data): flash('Password-protected archives are not allowed. Do NOT add a password yourself - the server handles this automatically.', FLASH_ERROR) - return redirect(f'/upload/solution/{hexidcrackme}') + return redirect(redirect_url) - # Check for PE files (patched binaries are not allowed) + # Check for PE files (patched binaries not allowed) if is_pe_file(file.filename, data): - flash('Executable files (PE binaries) are not allowed as solutions. ' - 'Please submit a writeup that analyzes the algorithm instead of a patched binary.', FLASH_ERROR) - return redirect(f'/upload/solution/{hexidcrackme}') + flash('Executable files (PE binaries) are not allowed as solutions. Please submit a writeup that analyzes the algorithm instead of a patched binary.', FLASH_ERROR) + return redirect(redirect_url) + + 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) - # Secure filename (fallback to "unnamed" if filename has only unsafe characters) original_filename = secure_filename(file.filename) or "unnamed" + ext = os.path.splitext(original_filename)[1].lower() + has_markdown = ext in MARKDOWN_EXTENSIONS - # Create solution try: - solution = solution_create(info, username, hexidcrackme, original_filename) + solution = solution_create(info, username, crackme, original_filename, has_markdown) except Exception as e: print(f"Error creating solution: {e}") abort(500) - # Create path using hexid only - safe_path = os.path.join(UPLOAD_FOLDER, solution['hexid']) - - # Ensure upload directory exists + # Save uploaded file os.makedirs(UPLOAD_FOLDER, exist_ok=True) - - # Save file try: - with open(safe_path, 'wb') as f: + with open(os.path.join(UPLOAD_FOLDER, solution['hexid']), 'wb') as f: f.write(data) except Exception as e: print(f"File write error: {e}") flash('An error occurred on the server. Please try again later.', FLASH_ERROR) - return redirect(f'/upload/solution/{hexidcrackme}') + return redirect(redirect_url) + + return _send_notifications_and_render_success(username, crackme) + + +@solution_bp.route('/solution/', methods=['GET']) +@login_required +def view_solution(hexid): + """Display a solution's writeup page.""" + solution = _get_solution_or_abort(hexid) + salt = get_obfuscation_salt(current_app.config) + + return render_template('solution/read.html', + solution=solution, + obfuscation_key=get_obfuscation_key_base64(hexid, salt)) + + +@solution_bp.route('/upload/solution//editor', methods=['GET']) +@login_required +def editor_solution_get(hexidcrackme): + """Display the web-based markdown editor for writing solutions.""" + crackme = _get_crackme_or_abort(hexidcrackme) + return render_template('solution/editor.html', + hexidcrackme=hexidcrackme, + username=crackme.get('author', ''), + crackmename=crackme.get('name', ''), + min_content_length=MIN_CONTENT_LENGTH, + max_content_length=MAX_CONTENT_LENGTH) + + +@solution_bp.route('/upload/solution//editor', methods=['POST']) +@login_required +@limit("20 per day", key_func=lambda: session.get('name')) +def editor_solution_post(hexidcrackme): + """Handle solution submission from the web editor.""" + crackme = _get_crackme_or_abort(hexidcrackme) + username = session.get('name') + redirect_url = f'/upload/solution/{hexidcrackme}/editor' + + # 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) + + if not verify_recaptcha(request): + flash('reCAPTCHA invalid!', FLASH_ERROR) + return redirect(redirect_url) + + content = request.form.get('content', '').strip() + + # Validate content length + 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) + + 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) + + 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) - # Send notification try: - crackme = crackme_by_hexid(hexidcrackme) - notification_add(username, f"Your solution for '{html_escape(crackme['name'])}' is waiting approval!") - # Send Discord notification - notify_new_solution(username, crackme['name']) + solution = solution_create(info, username, crackme, 'writeup.md', has_markdown=True) except Exception as e: - print(f"Notification error: {e}") + print(f"Error creating solution: {e}") + abort(500) - return render_template('submission/success.html', - submission_type='Writeup', - name=crackme['name'], - username=username) + # Save markdown content + os.makedirs(UPLOAD_FOLDER, exist_ok=True) + try: + with open(os.path.join(UPLOAD_FOLDER, solution['hexid']), 'w', encoding='utf-8') as f: + f.write(content) + except Exception as e: + print(f"File write error: {e}") + flash('An error occurred on the server. Please try again later.', FLASH_ERROR) + return redirect(redirect_url) + + return _send_notifications_and_render_success(username, crackme) diff --git a/app/models/solution.py b/app/models/solution.py index 798ec44..d84cbed 100644 --- a/app/models/solution.py +++ b/app/models/solution.py @@ -2,7 +2,7 @@ Solution model for database operations. """ -from datetime import datetime +from datetime import datetime, timezone from bson import ObjectId from pymongo import DESCENDING from app.services.database import get_collection, check_connection @@ -72,28 +72,24 @@ def solutions_by_user(username): return solutions -def solutions_by_user_and_crackme(username, crackme_hexid): - """Get solution by user and crackme.""" +def solution_exists(username, crackme_id): + """Check if a user has already submitted a solution for a crackme. + + Args: + username: The username to check + crackme_id: The crackme's ObjectId (not hexid) + + Returns: + True if a solution exists, False otherwise + """ if not check_connection(): raise ErrUnavailable("Database is unavailable") - # First get the crackme - from app.models.crackme import crackme_by_hexid - try: - crackme = crackme_by_hexid(crackme_hexid) - except ErrNoResult: - raise ErrNoResult("Solution not found") - collection = get_collection('solution') - result = collection.find_one({ - 'crackmeid': crackme['_id'], - 'author': username - }) - - if result is None: - raise ErrNoResult("Solution not found") - - return result + return collection.find_one( + {'crackmeid': crackme_id, 'author': username}, + {'_id': 1} + ) is not None def solutions_by_crackme(crackme_object_id): @@ -129,15 +125,11 @@ def get_solution_authors(crackme_hexid): return set(solution['author'] for solution in solutions) -def solution_create(info, username, crackme_hexid, original_filename): +def solution_create(info, username, crackme, original_filename=None, has_markdown=False): """Create a new solution.""" if not check_connection(): raise ErrUnavailable("Database is unavailable") - # Get the crackme - from app.models.crackme import crackme_by_hexid - crackme = crackme_by_hexid(crackme_hexid) - collection = get_collection('solution') obj_id = ObjectId() @@ -148,11 +140,12 @@ def solution_create(info, username, crackme_hexid, original_filename): 'crackmeid': crackme['_id'], 'crackmehexid': crackme['hexid'], 'crackmename': crackme['name'], - 'created_at': datetime.utcnow(), + 'created_at': datetime.now(timezone.utc), 'author': username, 'visible': False, 'deleted': False, - 'original_filename': original_filename + 'original_filename': original_filename, + 'has_markdown': has_markdown } collection.insert_one(solution) diff --git a/app/services/archive.py b/app/services/archive.py index 190374e..0574512 100644 --- a/app/services/archive.py +++ b/app/services/archive.py @@ -7,7 +7,7 @@ import struct # PE file extensions (case-insensitive) -PE_EXTENSIONS = {'.exe', '.dll', '.sys', '.scr', '.ocx', '.com', '.drv', '.cpl', '.efi'} +PE_EXTENSIONS = frozenset({'.exe', '.dll', '.sys', '.scr', '.ocx', '.com', '.drv', '.cpl', '.efi'}) def is_pe_file(filename: str, file_data: bytes) -> bool: diff --git a/app/services/crypto.py b/app/services/crypto.py new file mode 100644 index 0000000..691b17a --- /dev/null +++ b/app/services/crypto.py @@ -0,0 +1,40 @@ +""" +Provides simple XOR-based obfuscation for writeup content to prevent +trivial scraping while allowing client-side deobfuscation for display. + +NOTE: This is NOT encryption - the key is sent to the client. +""" + +import hashlib +import base64 +from itertools import cycle + +DEFAULT_OBFUSCATION_SALT = "crackmes-writeup-default-salt" + + +def get_obfuscation_salt(config: dict) -> str: + """Get the obfuscation salt from config, or return default.""" + salt = config.get('APP_CONFIG', {}).get('Writeup', {}).get('ObfuscationSalt') + if not salt or not isinstance(salt, str) or len(salt.strip()) == 0: + return DEFAULT_OBFUSCATION_SALT + return salt + + +def get_obfuscation_key_base64(hexid: str, salt: str) -> str: + """Derive a base64-encoded key from hexid and salt for client-side deobfuscation.""" + return base64.b64encode(_derive_key(hexid, salt)).decode('ascii') + + +def _derive_key(hexid: str, salt: str) -> bytes: + """Derive a 32-byte key from hexid and salt using SHA-256.""" + return hashlib.sha256(f"{hexid}:{salt}".encode('utf-8')).digest() + + +def _xor_bytes(data: bytes, key: bytes) -> bytes: + """XOR data with a repeating key.""" + return bytes(a ^ b for a, b in zip(data, cycle(key))) + + +def obfuscate_writeup(content: str, hexid: str, salt: str) -> bytes: + """Obfuscate writeup content for storage using XOR.""" + return _xor_bytes(content.encode('utf-8'), _derive_key(hexid, salt)) diff --git a/app/services/view.py b/app/services/view.py index 7bd8d9f..d80b17d 100644 --- a/app/services/view.py +++ b/app/services/view.py @@ -17,6 +17,9 @@ # Authorized characters for usernames/emails AUTHORIZED_CHARS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-@.+" +# Valid hex characters for MongoDB ObjectId validation +HEX_CHARS = frozenset('0123456789abcdef') + # Global view configuration view_config = {} @@ -40,6 +43,11 @@ def authorized_chars_only(s: str) -> bool: return True +def is_valid_hexid(hexid: str) -> bool: + """Check if a string is a valid MongoDB ObjectId (24 hex characters).""" + return bool(hexid) and len(hexid) == 24 and all(c in HEX_CHARS for c in hexid.lower()) + + def validate_required(form, required_fields): """Validate that all required fields are present. diff --git a/config/config.json.example b/config/config.json.example index c644d36..85fa7bb 100644 --- a/config/config.json.example +++ b/config/config.json.example @@ -37,5 +37,8 @@ }, "Site": { "BaseURL": "https://crackmes.one" + }, + "Writeup": { + "ObfuscationSalt": "change-this-to-a-secure-random-salt" } } diff --git a/review/routes.py b/review/routes.py index 12c1556..3572b4d 100644 --- a/review/routes.py +++ b/review/routes.py @@ -27,10 +27,12 @@ import bcrypt import requests -from rustyzipper import compress_file, EncryptionMethod +from rustyzipper import compress_file, EncryptionMethod, open_zip_stream_from_file from bson.objectid import ObjectId from review.logger import log_reviewer_operation +from app.services.crypto import obfuscate_writeup, get_obfuscation_salt +from app.services.view import is_valid_hexid # ============================================================================= @@ -49,6 +51,7 @@ PASSWORD_SALT = None DISCORD_WEBHOOK_PUBLIC = None SITE_BASE_URL = 'https://crackmes.one' +WRITEUP_OBFUSCATION_SALT = None g_crackmesone_db = None users = {} USERS_FILE = os.path.join(os.path.dirname(__file__), 'users.json') @@ -61,6 +64,12 @@ # Archive password for approved submissions ARCHIVE_PASSWORD = 'crackmes.one' +# Markdown file extensions for inline viewing +MARKDOWN_EXTENSIONS = frozenset({'.md', '.txt', '.markdown'}) + +# Max size for inline writeup content (1MB) +MAX_INLINE_CONTENT_SIZE = 1 * 1024 * 1024 + # ============================================================================= # Initialization @@ -78,7 +87,7 @@ def init_reviewer(app): - REVIEWER_PASSWORD_SALT: Salt for hashing reviewer passwords - DISCORD_CONFIG: Dict with Enabled and WebhookPublic keys """ - global PASSWORD_SALT, DISCORD_WEBHOOK_PUBLIC, SITE_BASE_URL, g_crackmesone_db, users + global PASSWORD_SALT, DISCORD_WEBHOOK_PUBLIC, SITE_BASE_URL, WRITEUP_OBFUSCATION_SALT, g_crackmesone_db, users PASSWORD_SALT = app.config.get( 'REVIEWER_PASSWORD_SALT', @@ -92,6 +101,8 @@ def init_reviewer(app): site_config = app.config.get('APP_CONFIG', {}).get('Site', {}) SITE_BASE_URL = site_config.get('BaseURL', 'https://crackmes.one') + WRITEUP_OBFUSCATION_SALT = get_obfuscation_salt(app.config) + from app.services.database import get_db g_crackmesone_db = get_db() @@ -251,22 +262,6 @@ def get_static_dir(item_type): return os.path.join(CRACKMESONE_DIR, 'static', item_type) -HEX_CHARS = set('0123456789abcdef') - - -def is_valid_hexid(hexid): - """ - Check if a string is a valid MongoDB ObjectId hex representation (24 hex characters). - - Args: - hexid: The string to validate - - Returns: - True if valid hexid, False otherwise - """ - return bool(hexid) and len(hexid) == 24 and all(c in HEX_CHARS for c in hexid.lower()) - - def find_pending_file(item_type, hexid): """ Find a pending submission file by its hexid. @@ -521,6 +516,48 @@ def create_password_protected_zip(source_path, dest_path_without_ext, filename_i return False, f"Error creating zip: {str(e)}" +def extract_markdown_content(source_path, original_filename): + """ + Extract markdown/text content from a file for inline viewing. + + Returns None if the file is too large, not a supported format, or on error. + """ + ext = os.path.splitext(original_filename)[1].lower() if original_filename else '' + + if ext in MARKDOWN_EXTENSIONS: + try: + # Check file size before reading + if os.path.getsize(source_path) > MAX_INLINE_CONTENT_SIZE: + return None + with open(source_path, 'r', encoding='utf-8', errors='replace') as f: + return f.read() + except Exception as e: + print(f"Could not read solution file: {e}") + return None + + if ext == '.zip': + try: + with open(source_path, 'rb') as f: + reader = open_zip_stream_from_file(f) + for name in reader.namelist(): + # Skip directories and hidden files + basename = os.path.basename(name) + if not basename or basename.startswith('.'): + continue + + file_ext = os.path.splitext(basename)[1].lower() + if file_ext in MARKDOWN_EXTENSIONS: + file_bytes = reader.read(name) + # Skip if content too large + if len(file_bytes) > MAX_INLINE_CONTENT_SIZE: + return None + return file_bytes.decode('utf-8', errors='replace') + except Exception as e: + print(f"Could not extract text content from zip: {e}") + + return None + + # ============================================================================= # Pending Submission Operations # ============================================================================= @@ -872,17 +909,27 @@ def approve_pending_solution(hexid): crackme_name = crackme["name"] + source_path = os.path.join(get_tmp_dir('solution'), hexid) + if not os.path.exists(source_path): + return False, "Solution file not found in tmp directory" + # Set visible g_crackmesone_db.solution.update_one( {'hexid': hexid}, {'$set': {'visible': True}} ) - # Create archive - source_path = os.path.join(get_tmp_dir('solution'), hexid) - if not os.path.exists(source_path): - return False, "Solution file not found in tmp directory" + raw_content = extract_markdown_content(source_path, original_filename) + if raw_content: + try: + obfuscated = obfuscate_writeup(raw_content, hexid, WRITEUP_OBFUSCATION_SALT) + raw_dest_path = os.path.join(get_static_dir('solution'), f"{hexid}.bin") + with open(raw_dest_path, 'wb') as f: + f.write(obfuscated) + except Exception as e: + print(f"Could not save solution for inline viewing: {e}") + # Create password-protected archive dest_path = os.path.join(get_static_dir('solution'), hexid) success, error = create_password_protected_zip( source_path, dest_path, original_filename @@ -953,6 +1000,13 @@ def delete_approved_solution(solution_uuid): except Exception: pass + # Delete encrypted .bin file (if exists) + bin_path = os.path.join(get_static_dir('solution'), f"{solution_uuid}.bin") + try: + os.remove(bin_path) + except Exception: + pass + # Delete from database g_crackmesone_db.solution.delete_one({"_id": ObjectId(solution_uuid)}) diff --git a/static/css/custom.css b/static/css/custom.css index 020c82c..0923f14 100644 --- a/static/css/custom.css +++ b/static/css/custom.css @@ -339,4 +339,154 @@ h3:hover .anchor-link { font-size: 14px; min-width: 120px; text-align: center; -} \ No newline at end of file +} + +/* WRITEUP CONTENT (Solution inline view) */ +#writeup-content { + background: #2a2a2a; + border: 1px solid #444; + border-radius: 4px; + padding: 1.5rem; + line-height: 1.7; + font-size: 0.95rem; +} + +#writeup-content h1, +#writeup-content h2, +#writeup-content h3, +#writeup-content h4, +#writeup-content h5, +#writeup-content h6 { + color: #9acc14; + margin-top: 1.5rem; + margin-bottom: 0.75rem; +} + +#writeup-content h1:first-child, +#writeup-content h2:first-child, +#writeup-content h3:first-child { + margin-top: 0; +} + +#writeup-content p { + margin-bottom: 1rem; +} + +#writeup-content pre { + background: #1a1a1a; + padding: 1rem; + overflow-x: auto; + border-radius: 4px; + border: 1px solid #333; +} + +#writeup-content code { + font-family: 'Consolas', 'Monaco', 'Courier New', monospace; + background: #1a1a1a; + padding: 0.2em 0.4em; + border-radius: 3px; + font-size: 0.9em; +} + +#writeup-content pre code { + padding: 0; + background: transparent; + border-radius: 0; +} + +#writeup-content img { + max-width: 100%; + height: auto; + border-radius: 4px; +} + +#writeup-content blockquote { + border-left: 4px solid #9acc14; + padding-left: 1rem; + margin-left: 0; + margin-right: 0; + color: #aaa; + font-style: italic; +} + +#writeup-content ul, +#writeup-content ol { + padding-left: 2rem; + margin-bottom: 1rem; +} + +#writeup-content li { + margin-bottom: 0.25rem; +} + +#writeup-content table { + border-collapse: collapse; + width: 100%; + margin-bottom: 1rem; +} + +#writeup-content th, +#writeup-content td { + border: 1px solid #444; + padding: 0.5rem 0.75rem; + text-align: left; +} + +#writeup-content th { + background: #333; + color: #9acc14; +} + +#writeup-content hr { + border: none; + border-top: 1px solid #444; + margin: 1.5rem 0; +} + +#writeup-content a { + color: #9acc14; +} + +#writeup-content a:hover { + text-decoration: underline; +} + +/* Code block wrapper with copy button */ +.code-block-wrapper { + position: relative; + margin-bottom: 1rem; +} + +.code-block-wrapper pre { + margin-bottom: 0; +} + +.copy-code-btn { + position: absolute; + top: 8px; + right: 8px; + padding: 4px 10px; + font-size: 12px; + background: #444; + color: #ccc; + border: 1px solid #555; + border-radius: 4px; + cursor: pointer; + opacity: 0; + transition: opacity 0.2s, background-color 0.2s; +} + +.code-block-wrapper:hover .copy-code-btn { + opacity: 1; +} + +.copy-code-btn:hover { + background: #555; + color: #fff; +} + +.copy-code-btn.copied { + background: #9acc14; + color: #000; + border-color: #9acc14; +} diff --git a/templates/crackme/read.html b/templates/crackme/read.html index d09cb65..f505743 100644 --- a/templates/crackme/read.html +++ b/templates/crackme/read.html @@ -210,7 +210,7 @@

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

Solution by {{ solution.author }} on {{ solution.created_at|PRETTYTIME }}:
{{ solution.info }}

{% endfor %} diff --git a/templates/partial/markdown.html b/templates/partial/markdown.html new file mode 100644 index 0000000..7df5bf2 --- /dev/null +++ b/templates/partial/markdown.html @@ -0,0 +1,32 @@ +{# Shared markdown rendering utilities - include in {% block foot %} #} + diff --git a/templates/solution/create.html b/templates/solution/create.html index c2f870a..af22b8f 100644 --- a/templates/solution/create.html +++ b/templates/solution/create.html @@ -17,6 +17,16 @@

Upload a Writeup

Read a full list of rules here

+
+ +
+
+

Prefer to write directly on the site? + Use Web Editor

+

Write your writeup in Markdown with live preview. Recommended for text-based writeups.

+
+
+
@@ -45,7 +55,6 @@

Upload a Writeup





{% endif %} -
{% include 'partial/footer.html' %} diff --git a/templates/solution/editor.html b/templates/solution/editor.html new file mode 100644 index 0000000..d65612a --- /dev/null +++ b/templates/solution/editor.html @@ -0,0 +1,141 @@ +{% extends "base.html" %} +{% block title %}Write Writeup{% endblock %} +{% block page_title %}Write Writeup{% endblock %} +{% block head %} + + +{% endblock %} +{% block content %} + +
+

Write a Writeup

+

Write your writeup directly in the browser using Markdown.

+ +

Read a full list of rules here

+ +
+ +
+ +
+
Crackme
+
{{ crackmename }} by {{ username }}
+
+
+
+ +
+
+ +
+
+
+
+ +
+
+ +
+ 0 / {{ "{:,}".format(max_content_length) }} characters (min {{ min_content_length }}) + Show Preview +
+
+
+ + {% if RECAPTCHA_SITEKEY %} +
+ {% endif %} + + Upload File Instead +
+
+ +{% include 'partial/footer.html' %} + +{% endblock %} +{% block foot %} +{% include 'partial/markdown.html' %} + +{% endblock %} diff --git a/templates/solution/read.html b/templates/solution/read.html new file mode 100644 index 0000000..a98b9e4 --- /dev/null +++ b/templates/solution/read.html @@ -0,0 +1,122 @@ +{% extends "base.html" %} +{% block title %}Writeup for {{ solution.crackmename }}{% endblock %} +{% block page_title %}Writeup{% endblock %} +{% block head %} + + +{% endblock %} +{% block content %} + +
+

Writeup for {{ solution.crackmename }}

+ +
+
+

+ Author: {{ solution.author }}
+ Date: {{ solution.created_at|PRETTYTIME }}
+ {% if solution.info %} + Summary: {{ solution.info }} + {% endif %} +

+
+
+ +
+ +
+

Loading writeup...

+
+ + + + + +
+ + +
+ +{% include 'partial/footer.html' %} + +{% endblock %} +{% block foot %} +{% include 'partial/markdown.html' %} + +{% endblock %} diff --git a/templates/user/read.html b/templates/user/read.html index 2284760..260c657 100644 --- a/templates/user/read.html +++ b/templates/user/read.html @@ -94,9 +94,10 @@

Writeups

- - + + + @@ -105,6 +106,9 @@

Writeups

+ {% endfor %}
CrackmeDateCrackmeDate InfosActions
{{ sol.crackmename }} {{ sol.solution.created_at|PRETTYTIME }} {{ sol.solution.info }} + View +