Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
509c19f
Add crackme obfuscation tags: display, search, submission, review (#17)
xusheng6 Jul 12, 2026
9cb3290
Add tag sub-labels nested under classes (#17)
xusheng6 Jul 12, 2026
96c941f
Fix tag chip wrapping and gate the AI caveat behind the "?" (#17)
xusheng6 Jul 12, 2026
4c5633a
Style tag chips as white-on-green to match the Download button (#17)
xusheng6 Jul 12, 2026
15aaed4
Make search bookmarkable via GET and link tag chips to it (#162, #17)
xusheng6 Jul 12, 2026
2b92e73
Tag UX: dual-list request modal, sub->parent auto-check, wording (#17)
xusheng6 Jul 12, 2026
d9a596e
Reusable tag transfer widget with move buttons, Undo and Reset (#17)
xusheng6 Jul 12, 2026
c9c107a
Fix tag transfer Undo/Reset, enlarge dialog, gate request link (#17)
xusheng6 Jul 12, 2026
4cb3087
Remove parent tag when its last sub-label is removed (#17)
xusheng6 Jul 12, 2026
6c61b68
Prompt anonymous users to log in to request a tag change (#17)
xusheng6 Jul 12, 2026
655514f
Expand packer/anti-debug sub-labels to match updated dataset (#17)
xusheng6 Jul 12, 2026
a4d7471
Sync tag vocabulary to latest dataset taxonomy (16 classes, 6 sub-lab…
xusheng6 Jul 12, 2026
ddcb25c
Store tag vocabulary in a DB collection instead of hardcoding it (#17)
xusheng6 Jul 12, 2026
93b6983
Use the upload page's grouped tag checkboxes on request/review/search…
xusheng6 Jul 14, 2026
9374ce4
Enlarge tag request modal; keep reason + submit visible (#17)
xusheng6 Jul 14, 2026
efada46
Auto-deselect a tag category when its last technique is unchecked (#17)
xusheng6 Jul 14, 2026
50521d1
Reset tag request dialog on open; add Undo/Reset buttons (#17)
xusheng6 Jul 14, 2026
dbad7a4
Don't let the browser cache the reviewer's edit-crackme draft (#17)
xusheng6 Jul 14, 2026
0e521cb
Merge tag vocabulary + import scripts into one sync_tags.py (#17)
xusheng6 Jul 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 73 additions & 13 deletions app/controllers/crackme.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,14 @@
from app.models.comment import comments_by_crackme
from app.models.rating import rating_difficulty_create, rating_quality_create, rating_difficulty_delete_by_crackme
from app.models.notification import notification_add
from app.models.tag_request import (
tag_request_create, pending_tag_requests_by_user_and_crackme
)
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.tags import get_tag_groups, get_dataset_url, normalize_tags
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.controllers.decorators import login_required
Expand Down Expand Up @@ -80,6 +84,9 @@ def crackme_view(hexid):
difficulty=f"{crackme.get('difficulty', 0):.1f}",
quality=f"{crackme.get('quality', 0):.1f}",
size=crackme.get('size', 0),
tags=crackme.get('tags', []),
tag_groups=get_tag_groups(),
tags_dataset_url=get_dataset_url(),
usersess=usersess)


Expand Down Expand Up @@ -134,7 +141,7 @@ def download_crackme(hexid):
@login_required
def upload_crackme_get():
"""Display the crackme upload form."""
return render_template('crackme/create.html')
return render_template('crackme/create.html', tag_groups=get_tag_groups())


@crackme_bp.route('/upload/crackme', methods=['POST'])
Expand All @@ -149,14 +156,17 @@ def upload_crackme_post():
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')
return render_template('crackme/create.html', tag_groups=get_tag_groups())

name = bleach.clean(request.form.get('name', ''))
info = bleach.clean(request.form.get('info', ''))
lang = bleach.clean(request.form.get('lang', ''))
arch = bleach.clean(request.form.get('arch', ''))
platform = request.form.get('platform', '')
difficulty = request.form.get('difficulty', '')
# Keep only values from the controlled vocabulary. Tags are not mandatory
# (a crackme with no matching technique may legitimately have none).
tags = normalize_tags(request.form.getlist('tags'))

# Validate difficulty
try:
Expand All @@ -165,45 +175,45 @@ def upload_crackme_post():
raise ValueError()
except (ValueError, TypeError):
flash('Wrong difficulty', FLASH_ERROR)
return render_template('crackme/create.html')
return render_template('crackme/create.html', tag_groups=get_tag_groups())

# Validate reCAPTCHA
if not verify_recaptcha(request):
flash('reCAPTCHA invalid!', FLASH_ERROR)
return render_template('crackme/create.html')
return render_template('crackme/create.html', tag_groups=get_tag_groups())

# Check for file
if 'file' not in request.files:
flash('Field missing: file', FLASH_ERROR)
return render_template('crackme/create.html')
return render_template('crackme/create.html', tag_groups=get_tag_groups())

file = request.files['file']
if file.filename == '':
flash('Field missing: file', FLASH_ERROR)
return render_template('crackme/create.html')
return render_template('crackme/create.html', tag_groups=get_tag_groups())

# 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')
return render_template('crackme/create.html', tag_groups=get_tag_groups())

# 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')
return render_template('crackme/create.html', tag_groups=get_tag_groups())

# 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')
return render_template('crackme/create.html', tag_groups=get_tag_groups())

# 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')
return render_template('crackme/create.html', tag_groups=get_tag_groups())

# Store the uploaded file size
size = len(file_data)
Expand All @@ -212,7 +222,7 @@ def upload_crackme_post():
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')
return render_template('crackme/create.html', tag_groups=get_tag_groups())
except ErrNoResult:
pass # No duplicate, continue

Expand All @@ -221,7 +231,7 @@ def upload_crackme_post():

# Prepare crackme
try:
crackme = crackme_create_prepare(name, info, username, lang, arch, platform, size, original_filename)
crackme = crackme_create_prepare(name, info, username, lang, arch, platform, size, original_filename, tags=tags)
except Exception as e:
print(f"Error preparing crackme: {e}")
abort(500)
Expand All @@ -239,7 +249,7 @@ def upload_crackme_post():
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')
return render_template('crackme/create.html', tag_groups=get_tag_groups())

# Insert crackme into database
try:
Expand Down Expand Up @@ -361,3 +371,53 @@ def edit_crackme_post(hexid):
flash('No changes were made.', FLASH_SUCCESS)

return redirect(f'/crackme/{hexid}')


@crackme_bp.route('/crackme/<hexid>/tags/request', methods=['POST'])
@login_required
@limit("20 per day", key_func=lambda: session.get('name'))
def request_tag_change(hexid):
"""Submit a request to add and/or remove tags on a crackme.

Requests are queued for reviewers; nothing changes on the crackme until a
reviewer approves.
"""
username = session.get('name')

try:
crackme = crackme_by_hexid(hexid)
except ErrNoResult:
abort(404)
except Exception as e:
print(f"Error getting crackme: {e}")
abort(500)

current = crackme.get('tags', [])
# The form submits the full desired set of applied tags; derive the add/remove
# sets by diffing against what the crackme currently carries.
desired = normalize_tags(request.form.getlist('applied'))
add = [t for t in desired if t not in current]
remove = [t for t in current if t not in desired]
note = bleach.clean(request.form.get('note', ''))[:500]

if not add and not remove:
flash('No tag changes were selected.', FLASH_ERROR)
return redirect(f'/crackme/{hexid}')

# Prevent a user from piling up duplicate pending requests on one crackme.
try:
if pending_tag_requests_by_user_and_crackme(username, hexid) > 0:
flash('You already have a pending tag change request for this crackme.', FLASH_ERROR)
return redirect(f'/crackme/{hexid}')
except Exception as e:
print(f"Error checking pending tag requests: {e}")

try:
tag_request_create(hexid, crackme.get('name', ''), username,
add=add, remove=remove, note=note)
except Exception as e:
print(f"Error creating tag request: {e}")
abort(500)

flash('Tag change request submitted for review. Thank you!', FLASH_SUCCESS)
return redirect(f'/crackme/{hexid}')
82 changes: 53 additions & 29 deletions app/controllers/search.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,60 +2,79 @@
Search controller - Searching crackmes.
"""

from flask import Blueprint, render_template, request, redirect, url_for
from flask import Blueprint, render_template, request
from app.models.crackme import search_crackme, random_crackmes
from app.models.errors import ErrUnavailable
from app.services.tags import get_tag_groups, normalize_tags

search_bp = Blueprint('search', __name__)


@search_bp.route('/search', methods=['GET'])
def search_get():
"""Display the search page."""
return render_template('search/search.html', crackmes=[], page=1, has_more=False, show_all=False, search_params={})
"""Display the search page.

Searches are driven by URL query parameters so results are bookmarkable and
shareable (issue #162). A bare ``/search`` with no parameters shows the empty
form; any parameter (e.g. ``/search?tags=Packer``) runs a search.
"""
if not request.args:
return render_template('search/search.html', crackmes=[], page=1, has_more=False,
show_all=False, search_params={}, tag_groups=get_tag_groups())
return _render_search(request.args)


@search_bp.route('/search', methods=['POST'])
def search_post():
"""Handle search form submission."""
name = request.form.get('name', '')
author = request.form.get('author', '')
"""Handle a POST search submission (kept for backward compatibility)."""
return _render_search(request.form)


def _render_search(source):
"""Run a crackme search from a request MultiDict (``request.args`` or form).

``source`` provides ``.get`` and ``.getlist``, so the same logic serves both
bookmarkable GET URLs and legacy POST submissions.
"""
name = source.get('name', '')
author = source.get('author', '')
# Use getlist() for multi-select fields
lang = request.form.getlist('lang')
arch = request.form.getlist('arch')
platform = request.form.getlist('platform')
lang = source.getlist('lang')
arch = source.getlist('arch')
platform = source.getlist('platform')
tags = normalize_tags(source.getlist('tags'))

# Get difficulty range
try:
difficulty_min = int(request.form.get('difficulty-min', 1))
difficulty_min = int(source.get('difficulty-min', 1))
except (ValueError, TypeError):
difficulty_min = 1

try:
difficulty_max = int(request.form.get('difficulty-max', 6))
difficulty_max = int(source.get('difficulty-max', 6))
except (ValueError, TypeError):
difficulty_max = 6

# Get quality range
try:
quality_min = int(request.form.get('quality-min', 1))
quality_min = int(source.get('quality-min', 1))
except (ValueError, TypeError):
quality_min = 1

try:
quality_max = int(request.form.get('quality-max', 6))
quality_max = int(source.get('quality-max', 6))
except (ValueError, TypeError):
quality_max = 6

# Get downloads range
try:
downloads_min = int(request.form.get('downloads-min', 0))
downloads_min = int(source.get('downloads-min', 0))
if downloads_min < 0:
downloads_min = 0
except (ValueError, TypeError):
downloads_min = 0

downloads_max_str = request.form.get('downloads-max', '')
downloads_max_str = source.get('downloads-max', '')
if downloads_max_str == '' or downloads_max_str.lower() == 'any':
downloads_max = None
else:
Expand All @@ -68,13 +87,13 @@ def search_post():

# Get solutions range
try:
solutions_min = int(request.form.get('solutions-min', 0))
solutions_min = int(source.get('solutions-min', 0))
if solutions_min < 0:
solutions_min = 0
except (ValueError, TypeError):
solutions_min = 0

solutions_max_str = request.form.get('solutions-max', '')
solutions_max_str = source.get('solutions-max', '')
if solutions_max_str == '' or solutions_max_str.lower() == 'any':
solutions_max = None
else:
Expand All @@ -87,13 +106,13 @@ def search_post():

# Get comments range
try:
comments_min = int(request.form.get('comments-min', 0))
comments_min = int(source.get('comments-min', 0))
if comments_min < 0:
comments_min = 0
except (ValueError, TypeError):
comments_min = 0

comments_max_str = request.form.get('comments-max', '')
comments_max_str = source.get('comments-max', '')
if comments_max_str == '' or comments_max_str.lower() == 'any':
comments_max = None
else:
Expand Down Expand Up @@ -125,31 +144,31 @@ def parse_size_with_unit(value_str, unit_str):
except (ValueError, TypeError):
return None

size_min_str = request.form.get('size-min', '')
size_min_unit = request.form.get('size-min-unit', 'KB')
size_min_str = source.get('size-min', '')
size_min_unit = source.get('size-min-unit', 'KB')
size_min = parse_size_with_unit(size_min_str, size_min_unit)
if size_min is None:
size_min = 0

size_max_str = request.form.get('size-max', '')
size_max_unit = request.form.get('size-max-unit', 'MB')
size_max_str = source.get('size-max', '')
size_max_unit = source.get('size-max-unit', 'MB')
size_max = parse_size_with_unit(size_max_str, size_max_unit)

# Get page number and show_all flag
try:
page = int(request.form.get('page', 1))
page = int(source.get('page', 1))
if page < 1:
page = 1
except (ValueError, TypeError):
page = 1

show_all = request.form.get('show_all') == '1'
show_all = source.get('show_all') == '1'

# Get sort options
sort_by = request.form.get('sort_by', 'date')
sort_by = source.get('sort_by', 'date')
if sort_by not in ('date', 'size', 'downloads', 'solutions', 'comments', 'quality', 'difficulty'):
sort_by = 'date'
sort_order = request.form.get('sort_order', 'desc')
sort_order = source.get('sort_order', 'desc')
if sort_order not in ('asc', 'desc'):
sort_order = 'desc'

Expand All @@ -161,6 +180,7 @@ def parse_size_with_unit(value_str, unit_str):
'lang': lang, # list
'arch': arch, # list
'platform': platform, # list
'tags': tags, # list
'difficulty-min': difficulty_min,
'difficulty-max': difficulty_max,
'quality-min': quality_min,
Expand All @@ -187,6 +207,7 @@ def parse_size_with_unit(value_str, unit_str):
lang=lang,
arch=arch,
platform=platform,
tags=tags,
difficulty_min=difficulty_min,
difficulty_max=difficulty_max,
quality_min=quality_min,
Expand All @@ -212,6 +233,7 @@ def parse_size_with_unit(value_str, unit_str):
lang=lang,
arch=arch,
platform=platform,
tags=tags,
difficulty_min=difficulty_min,
difficulty_max=difficulty_max,
quality_min=quality_min,
Expand All @@ -238,7 +260,8 @@ def parse_size_with_unit(value_str, unit_str):
page=page,
has_more=has_more,
show_all=show_all,
search_params=search_params)
search_params=search_params,
tag_groups=get_tag_groups())


@search_bp.route('/random', methods=['GET'])
Expand Down Expand Up @@ -276,4 +299,5 @@ def random_get():
page=1,
has_more=False,
show_all=True,
search_params=search_params)
search_params=search_params,
tag_groups=get_tag_groups())
Loading
Loading