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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 34 additions & 1 deletion app/controllers/index.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,13 @@
Index controller - Home page.
"""

from flask import Blueprint, render_template
from math import ceil

from flask import Blueprint, abort, redirect, render_template
from app.models.user import count_users
from app.models.crackme import count_crackmes
from app.models.solution import count_solutions
from app.models.solve import scoreboard

index_bp = Blueprint('index', __name__)

Expand All @@ -25,3 +28,33 @@ def index():
nbusers=nbusers,
nbcrackmes=nbcrackmes,
nbsolutions=nbsolutions)


@index_bp.route('/scoreboard')
def scoreboard_index():
return _scoreboard_page(1)


def _scoreboard_page(page):
try:
rows, has_more, total = scoreboard(page)
except Exception as e:
print(f"Error getting scoreboard: {e}")
abort(500)

if page > 1 and not rows:
last_page = max(1, ceil(total / 25))
return redirect('/scoreboard' if last_page == 1
else f'/scoreboard/{last_page}')

return render_template('scoreboard/index.html',
scoreboard=rows,
page=page,
has_more=has_more)


@index_bp.route('/scoreboard/<int:page>')
def scoreboard_page(page):
if page < 2:
return redirect('/scoreboard')
return _scoreboard_page(page)
62 changes: 62 additions & 0 deletions app/models/solve.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,68 @@ def user_score(user_hexid):
return sum(solve.get('points', 0) for solve in solves)


def scoreboard(page=1, per_page=25):
"""Rank visible players by score, then by when they reached that score.

Scores remain derived from solve records. User documents are resolved
after aggregation so deleted, hidden, or otherwise missing accounts never
appear on the public scoreboard.
"""
if not check_connection():
raise ErrUnavailable("Database is unavailable")

from app.models.user import users_by_hexids

totals = list(get_collection('solve').aggregate([
{'$group': {
'_id': '$user_hexid',
'score': {'$sum': {'$ifNull': ['$points', 0]}},
'last_solve_at': {'$max': {
'$cond': [{'$gt': ['$points', 0]}, '$created_at', None]
}},
'last_undated_solve_id': {'$max': {
'$cond': [{'$and': [
{'$gt': ['$points', 0]},
{'$eq': [{'$ifNull': ['$created_at', None]}, None]},
]}, '$_id', None]
}},
}},
{'$match': {'score': {'$gt': 0}}},
]))
users = users_by_hexids(total['_id'] for total in totals)

def reached_at(total):
candidates = []
if total.get('last_solve_at'):
candidates.append(total['last_solve_at'])
if total.get('last_undated_solve_id'):
candidates.append(total['last_undated_solve_id'].generation_time)
return max(timestamp.replace(tzinfo=timezone.utc)
if timestamp.tzinfo is None else timestamp
for timestamp in candidates)

rows = [
{
'user_hexid': total['_id'],
'username': users[total['_id']]['name'],
'score': total['score'],
'reached_at': reached_at(total),
}
for total in totals
if total['_id'] in users and not users[total['_id']].get('deleted', False)
]
rows.sort(key=lambda row: (-row['score'], row['reached_at'],
row['user_hexid']))

for position, row in enumerate(rows, start=1):
row['rank'] = position

page = max(page, 1)
start = (page - 1) * per_page
paginated_rows = rows[start:start + per_page]
return paginated_rows, start + per_page < len(rows), len(rows)


def count_solves_by_crackme(crackme_hexid):
"""Count how many users have solved a crackme."""
if not check_connection():
Expand Down
4 changes: 4 additions & 0 deletions templates/partial/menu.html
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ <h2><a href="/" class="title-navbar">crackmes.one</a></h2>
<a href="{{ BaseURI }}search" class="btn btn-link">Search</a>
<a href="{{ BaseURI }}upload/crackme" class="btn btn-link">Upload crackme</a>
<a href="{{ BaseURI }}latest" class="btn btn-link">Latest</a>
<a href="{{ BaseURI }}scoreboard" class="btn btn-link">Scoreboard</a>
<a href="{{ BaseURI }}faq" class="btn btn-link">Faq</a>
<a href="https://discord.gg/2pPV3yq" class="btn btn-link">Discord</a>
<a href="https://blog.crackmes.one/" class="btn btn-link" target="_blank">Blog</a>
Expand All @@ -32,6 +33,7 @@ <h2 class="text-center"><a href="/" class="title-navbar">crackmes.one</a></h2>
<li class="nav"><a href="{{ BaseURI }}search" class="btn btn-link">Search</a>
<li class="nav"><a href="{{ BaseURI }}upload/crackme" class="btn btn-link">Upload crackme</a>
<li class="nav"><a href="{{ BaseURI }}latest" class="btn btn-link">Latest</a></li>
<li class="nav"><a href="{{ BaseURI }}scoreboard" class="btn btn-link">Scoreboard</a></li>
<li class="nav"><a href="https://discord.gg/2pPV3yq" class="btn btn-link">Discord</a></li>
<li class="nav"><a href="{{ BaseURI }}faq" class="btn btn-link">Faq</a></li>
<li class="nav"><a href="https://blog.crackmes.one/" class="btn btn-link" target="_blank">Blog</a></li>
Expand All @@ -48,6 +50,7 @@ <h2 class="text-center"><a href="/" class="title-navbar">crackmes.one</a></h2>
<section class="navbar-section">
<a href="{{ BaseURI }}search" class="btn btn-link">Search</a>
<a href="{{ BaseURI }}latest" class="btn btn-link">Latest</a>
<a href="{{ BaseURI }}scoreboard" class="btn btn-link">Scoreboard</a>
<a href="{{ BaseURI }}faq" class="btn btn-link">Faq</a>
<a href="https://discord.gg/2pPV3yq" class="btn btn-link">Discord</a>
<a href="https://blog.crackmes.one/" class="btn btn-link" target="_blank">Blog</a>
Expand All @@ -66,6 +69,7 @@ <h2 class="text-center"><a href="/" class="title-navbar">crackmes.one</a></h2>
<ul class="nav">
<li class="nav"><a href="{{ BaseURI }}search" class="btn btn-link">Search</a>
<li class="nav"><a href="{{ BaseURI }}latest" class="btn btn-link">Latest</a></li>
<li class="nav"><a href="{{ BaseURI }}scoreboard" class="btn btn-link">Scoreboard</a></li>
<li class="nav"><a href="{{ BaseURI }}faq" class="btn btn-link">Faq</a></li>
<li class="nav"><a href="https://discord.gg/2pPV3yq" class="btn btn-link">Discord</a></li>
<li class="nav"><a href="https://blog.crackmes.one/" class="btn btn-link" target="_blank">Blog</a></li>
Expand Down
46 changes: 46 additions & 0 deletions templates/scoreboard/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
{% extends "base.html" %}
{% block title %}Scoreboard{% endblock %}
{% block page_title %}Scoreboard{% endblock %}
{% block content %}
<div class="container grid-lg wrapper">
<h2>Scoreboard</h2>
{% if scoreboard %}
<div class="table-responsive">
<table class="table table-striped">
<thead>
<tr>
<th style="width: 15%;">Rank</th>
<th>Player</th>
<th class="text-right" style="width: 25%;">Score</th>
</tr>
</thead>
<tbody>
{% for player in scoreboard %}
<tr>
<td>{{ player.rank }}</td>
<td><a href="/user/{{ player.username|urlencode }}">{{ player.username }}</a></td>
<td class="text-right">{{ player.score }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="pagination-controls">
{% if page > 1 %}
<a href="{% if page == 2 %}/scoreboard{% else %}/scoreboard/{{ page - 1 }}{% endif %}" class="btn active">Previous</a>
{% else %}
<button class="btn" disabled>Previous</button>
{% endif %}
<span class="pagination-info">Page {{ page }}</span>
{% if has_more %}
<a href="/scoreboard/{{ page + 1 }}" class="btn active">Next</a>
{% else %}
<button class="btn" disabled>Next</button>
{% endif %}
</div>
{% else %}
<p>No players have scored points yet.</p>
{% endif %}
</div>
{% include 'partial/footer.html' %}
{% endblock %}
2 changes: 1 addition & 1 deletion tests/test_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
def test_public_pages_load(client):
for path in ('/', '/faq', '/upload/crackmerules', '/upload/writeuprules',
'/login', '/register', '/search', '/latest', '/lasts/1',
'/latest/solutions/1', '/latest/solves/1'):
'/latest/solutions/1', '/latest/solves/1', '/scoreboard'):
assert client.get(path).status_code == 200, path


Expand Down
142 changes: 142 additions & 0 deletions tests/test_scoreboard.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
"""Scoreboard ranking, navigation, and pagination."""

from datetime import datetime, timedelta, timezone

from bson import ObjectId


def _user(db, name, *, visible=True, deleted=False):
object_id = ObjectId()
user = {
'_id': object_id,
'hexid': str(object_id),
'name': name,
'visible': visible,
'deleted': deleted,
}
db.user.insert_one(user)
return user


def _solve(db, user, points, index=0, created_at=None):
object_id = ObjectId()
db.solve.insert_one({
'_id': object_id,
'hexid': str(object_id),
'user_hexid': user.get('hexid') or str(user['_id']),
'crackme_hexid': f'crackme-{index}',
'points': points,
'created_at': created_at or datetime.now(timezone.utc),
})


def test_scoreboard_orders_scores_and_excludes_zero_or_hidden_users(
client, db):
alice = _user(db, 'alice')
bob = _user(db, 'bob')
zero = _user(db, 'zero')
hidden = _user(db, 'hidden', visible=False)
deleted = _user(db, 'deleted', deleted=True)
_solve(db, alice, 200)
_solve(db, alice, 300, 1)
_solve(db, bob, 400)
_solve(db, zero, 0)
_solve(db, hidden, 900)
_solve(db, deleted, 800)

html = client.get('/scoreboard').get_data(as_text=True)

assert html.index('>alice</a>') < html.index('>bob</a>')
assert '<td class="text-right">500</td>' in html
assert '<td class="text-right">400</td>' in html
assert '>zero</a>' not in html
assert '>hidden</a>' not in html
assert '>deleted</a>' not in html


def test_scoreboard_ties_rank_by_time_of_reaching_score(client, db):
zed = _user(db, 'zed')
amy = _user(db, 'Amy')
lower = _user(db, 'lower')
start = datetime(2026, 1, 1, tzinfo=timezone.utc)
_solve(db, zed, 200, created_at=start)
_solve(db, amy, 500, created_at=start + timedelta(minutes=1))
_solve(db, zed, 300, 1, created_at=start + timedelta(minutes=2))
_solve(db, lower, 400, created_at=start + timedelta(minutes=3))

html = client.get('/scoreboard').get_data(as_text=True)

assert html.index('>Amy</a>') < html.index('>zed</a>')
assert '<td>1</td>' in html
assert '<td>2</td>' in html
assert '<td>3</td>' in html


def test_scoreboard_ties_with_legacy_solve_without_timestamp(client, db):
legacy = _user(db, 'legacy')
newer = _user(db, 'newer')
legacy_id = ObjectId.from_datetime(datetime(2025, 1, 1, tzinfo=timezone.utc))
db.solve.insert_one({
'_id': legacy_id,
'user_hexid': legacy['hexid'],
'points': 100,
})
_solve(db, newer, 100, created_at=datetime(2026, 1, 1, tzinfo=timezone.utc))

html = client.get('/scoreboard').get_data(as_text=True)
assert html.index('>legacy</a>') < html.index('>newer</a>')


def test_scoreboard_uses_latest_legacy_solve_among_mixed_formats(client, db):
mixed = _user(db, 'mixed')
other = _user(db, 'other')
start = datetime(2026, 1, 1, tzinfo=timezone.utc)
_solve(db, mixed, 50, created_at=start)
_solve(db, other, 100, created_at=start + timedelta(days=1))
db.solve.insert_one({
'_id': ObjectId.from_datetime(start + timedelta(days=2)),
'user_hexid': mixed['hexid'],
'points': 50,
})

html = client.get('/scoreboard').get_data(as_text=True)
assert html.index('>other</a>') < html.index('>mixed</a>')


def test_zero_point_solve_does_not_change_time_score_was_reached(client, db):
first = _user(db, 'first')
second = _user(db, 'second')
start = datetime(2026, 1, 1, tzinfo=timezone.utc)
_solve(db, first, 500, created_at=start)
_solve(db, second, 500, created_at=start + timedelta(days=1))
_solve(db, first, 0, 1, created_at=start + timedelta(days=2))

html = client.get('/scoreboard').get_data(as_text=True)
assert html.index('>first</a>') < html.index('>second</a>')


def test_scoreboard_paginates_with_global_rank(client, db):
for index in range(26):
user = _user(db, f'player-{index:02d}')
_solve(db, user, 1000 - index, index)

first_page = client.get('/scoreboard').get_data(as_text=True)
assert '>player-00</a>' in first_page
assert '>player-24</a>' in first_page
assert '>player-25</a>' not in first_page
assert 'href="/scoreboard/2"' in first_page

second_page = client.get('/scoreboard/2').get_data(as_text=True)
assert '>player-00</a>' not in second_page
assert '>player-25</a>' in second_page
assert '<td>26</td>' in second_page
assert 'href="/scoreboard"' in second_page
assert client.get('/scoreboard/0').location == '/scoreboard'
assert client.get('/scoreboard/99').location == '/scoreboard/2'


def test_scoreboard_is_linked_from_navigation_but_not_embedded_on_homepage(client):
home = client.get('/').get_data(as_text=True)
assert 'href="/scoreboard" class="btn btn-link">Scoreboard</a>' in home
assert '<h2>Scoreboard</h2>' not in home
assert client.get('/scoreboard').status_code == 200
Loading