Skip to content
Open
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
2 changes: 2 additions & 0 deletions dojo_plugin/api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from .v1.workspace_tokens import workspace_tokens_namespace
from .v1.workspace import workspace_namespace
from .v1.search import search_namespace
from .v1.privacy import privacy_namespace


api = Blueprint("pwncollege_api", __name__)
Expand All @@ -26,3 +27,4 @@
api_v1.add_namespace(workspace_tokens_namespace, "/workspace_tokens")
api_v1.add_namespace(workspace_namespace, "/workspace")
api_v1.add_namespace(search_namespace, "/search")
api_v1.add_namespace(privacy_namespace, "/privacy")
31 changes: 31 additions & 0 deletions dojo_plugin/api/v1/privacy.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
from flask import request
from flask_restx import Namespace, Resource
from CTFd.models import db
from CTFd.utils.decorators import authed_only
from CTFd.utils.user import get_current_user

from ...models import UserPrivacySettings


privacy_namespace = Namespace(
"privacy", description="Endpoint to manage users' privacy settings"
)


@privacy_namespace.route("")
class PrivacySettings(Resource):
@authed_only
def post(self):
data = request.get_json()
user = get_current_user()

privacy_settings = UserPrivacySettings.get_or_create(user.id)

privacy_settings.show_discord = data.get("show_discord", False)
privacy_settings.show_activity = data.get("show_activity", False)
privacy_settings.show_solve_data = data.get("show_solve_data", False)
privacy_settings.show_username_in_activity = data.get("show_username_in_activity", False)

db.session.commit()

return {"success": True}
23 changes: 23 additions & 0 deletions dojo_plugin/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -885,6 +885,29 @@ def __repr__(self):
return f"<{self.__class__.__name__} {self.id!r}>"


class UserPrivacySettings(db.Model):
__tablename__ = "user_privacy_settings"

user_id = db.Column(db.Integer, db.ForeignKey("users.id", ondelete="CASCADE"), primary_key=True)
show_discord = db.Column(db.Boolean, default=True, nullable=False)
show_activity = db.Column(db.Boolean, default=True, nullable=False)
show_solve_data = db.Column(db.Boolean, default=True, nullable=False)
show_username_in_activity = db.Column(db.Boolean, default=True, nullable=False)

user = db.relationship("Users")

@classmethod
def get_or_create(cls, user_id):
settings = cls.query.filter_by(user_id=user_id).first()
if not settings:
settings = cls(user_id=user_id)
db.session.add(settings)
db.session.commit()
return settings

__repr__ = columns_repr(["user_id", "show_discord", "show_activity", "show_solve_data", "show_username_in_activity"])


for deferral in deferred_definitions:
deferral()
del deferred_definitions
5 changes: 4 additions & 1 deletion dojo_plugin/pages/dojo.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
from CTFd.utils.helpers import get_infos

from ..utils import get_current_container, get_all_containers, render_markdown
from ..utils.stats import get_container_stats, get_dojo_stats
from ..utils.stats import get_container_stats, get_dojo_stats, get_challenge_active_users
from ..utils.dojo import dojo_route, get_current_dojo_challenge, dojo_update, dojo_admins_only
from ..models import Dojos, DojoUsers, DojoStudents, DojoModules, DojoMembers, DojoChallenges

Expand Down Expand Up @@ -285,6 +285,8 @@ def view_module(dojo, module):
for container in get_container_stats()
if container["module"] == module.id and container["dojo"] == dojo.reference_id
)

challenge_active_users = get_challenge_active_users()

return render_template(
"module.html",
Expand All @@ -297,6 +299,7 @@ def view_module(dojo, module):
current_dojo_challenge=current_dojo_challenge,
assessments=assessments,
challenge_container_counts=challenge_container_counts,
challenge_active_users=challenge_active_users,
)


Expand Down
5 changes: 4 additions & 1 deletion dojo_plugin/pages/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from CTFd.utils.decorators import authed_only
from CTFd.utils.user import get_current_user

from ..models import SSHKeys, DiscordUsers
from ..models import SSHKeys, DiscordUsers, UserPrivacySettings
from ..config import DISCORD_CLIENT_ID
from ..utils.discord import get_discord_member, discord_avatar_asset

Expand All @@ -21,6 +21,8 @@ def settings_override():

discord_member = get_discord_member(DiscordUsers.query.filter_by(user=user)
.with_entities(DiscordUsers.discord_id).scalar())

privacy_settings = UserPrivacySettings.get_or_create(user.id)

prevent_name_change = get_config("prevent_name_change")

Expand All @@ -43,5 +45,6 @@ def settings_override():
discord_member=discord_member,
discord_avatar_asset=discord_avatar_asset,
prevent_name_change=prevent_name_change,
privacy_settings=privacy_settings,
infos=infos,
)
13 changes: 11 additions & 2 deletions dojo_plugin/pages/users.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,10 @@
from CTFd.models import db, Users, Challenges, Solves
from CTFd.cache import cache

from ..models import Dojos, DojoModules, DojoChallenges
from ..models import Dojos, DojoModules, DojoChallenges, DiscordUsers, UserPrivacySettings
from ..utils.scores import dojo_scores, module_scores
from ..utils.awards import get_belts, get_viewable_emojis
from ..utils.discord import get_discord_member


users = Blueprint("pwncollege_users", __name__)
Expand All @@ -39,12 +40,20 @@ def view_hacker(user, bypass_hidden=False):
user_solves[dojo_id][module_id] = {
solve.challenge_id: solve.date.strftime("%Y-%m-%d %H:%M:%S") for solve in solves
}

discord_user = DiscordUsers.query.filter_by(user_id=user.id).first()
discord_member = get_discord_member(discord_user.discord_id) if discord_user else None

user_privacy = UserPrivacySettings.get_or_create(user.id)

return render_template(
"hacker.html",
dojos=dojos, user=user,
dojo_scores=dojo_scores(), module_scores=module_scores(),
belts=get_belts(), badges=get_viewable_emojis(get_current_user()),
user_solves=user_solves
user_solves=user_solves,
discord_member=discord_member,
user_privacy=user_privacy
)

@users.route("/hacker/<int:user_id>")
Expand Down
61 changes: 60 additions & 1 deletion dojo_plugin/utils/stats.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
from CTFd.cache import cache
from CTFd.models import Solves
from CTFd.models import Solves, Users
from datetime import datetime, timedelta
from sqlalchemy import func, desc

from . import force_cache_updates, get_all_containers, DojoChallenges
from ..models import UserPrivacySettings

@cache.memoize(timeout=1200, forced_update=force_cache_updates)
def get_container_stats():
Expand All @@ -12,6 +13,64 @@ def get_container_stats():
for attr in ["dojo", "module", "challenge"]}
for container in containers]

@cache.memoize(timeout=10, forced_update=force_cache_updates)
def get_challenge_active_users():
containers = get_all_containers()
challenge_users = {}

for container in containers:
try:
challenge_id = container.labels.get("dojo.challenge_id")
user_id = container.labels.get("dojo.user_id")

if challenge_id and user_id:
if challenge_id not in challenge_users:
challenge_users[challenge_id] = {}
challenge_users[challenge_id][int(user_id)] = {
'container': container,
'started_at': container.attrs['State']['StartedAt']
}
except (KeyError, ValueError):
continue

# Convert to list of user data with names and durations
result = {}
for challenge_id, user_containers in challenge_users.items():
users_data = []
for user_id, container_info in user_containers.items():
user = Users.query.filter_by(id=user_id).first()
if user and not user.hidden: # Respect basic privacy settings
# Check if user allows username in activity display
privacy_settings = UserPrivacySettings.get_or_create(user_id)
if privacy_settings.show_username_in_activity:
# Calculate duration
from datetime import datetime
import dateutil.parser

started_at = dateutil.parser.parse(container_info['started_at'])
now = datetime.now(started_at.tzinfo)
duration = now - started_at

# Format duration
total_seconds = int(duration.total_seconds())
hours = total_seconds // 3600
minutes = (total_seconds % 3600) // 60

if hours > 0:
duration_str = f"{hours}h {minutes}m"
else:
duration_str = f"{minutes}m"

users_data.append({
'id': user.id,
'name': user.name,
'display_name': user.name,
'duration': duration_str
})
result[challenge_id] = users_data

return result

@cache.memoize(timeout=1200, forced_update=force_cache_updates)
def get_dojo_stats(dojo):
now = datetime.now()
Expand Down
73 changes: 73 additions & 0 deletions dojo_theme/static/css/custom.css
Original file line number Diff line number Diff line change
Expand Up @@ -1062,3 +1062,76 @@ code {
background-color: rgba(0, 0, 0, 0.8);
}
}

/* Hacker dropdown styles */
.total-hackers-wrapper {
position: relative;
display: inline-block;
}

.total-hackers {
cursor: help;
border-bottom: 1px dashed var(--brand-light-gray);
}

.hacker-list-dropdown {
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
background: #2a2a2a;
border: 1px solid #444;
border-radius: 4px;
padding: 10px 15px;
margin-top: 0;
min-width: 200px;
max-width: 300px;
z-index: 1000;
display: none;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
line-height: 1.6;
}

.total-hackers-wrapper:hover .hacker-list-dropdown {
display: block;
}

/* Invisible bridge to maintain hover between text and dropdown */
.hacker-list-dropdown::before {
content: '';
position: absolute;
top: -5px;
left: 0;
right: 0;
height: 5px;
}

.hacker-entry {
display: flex;
justify-content: space-between;
align-items: center;
padding: 2px 0;
}

.hacker-link {
color: var(--brand-blue);
text-decoration: none;
transition: color 0.2s ease;
}

.hacker-link:hover {
color: var(--brand-gold);
text-decoration: underline;
}

.hacker-duration {
color: var(--brand-light-gray);
font-size: 0.85em;
margin-left: 10px;
}

.more-hackers {
color: var(--brand-light-gray);
font-style: italic;
font-size: 0.9em;
}
1 change: 1 addition & 0 deletions dojo_theme/static/js/dojo/settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ function button_fetch_and_show(name, endpoint, method,data, success_message, abo
$(() => {
form_fetch_and_show("ssh-key", "/pwncollege_api/v1/ssh_key", "POST", "Your public key has been updated");
form_fetch_and_show("discord", "/pwncollege_api/v1/discord", "DELETE", "Your discord account has been disconnected");
form_fetch_and_show("privacy", "/pwncollege_api/v1/privacy", "POST", "Your privacy settings have been updated");
form_fetch_and_show("dojo-create", "/pwncollege_api/v1/dojos/create", "POST", "Your dojo has been created");
form_fetch_and_show("dojo-promote-admin", `/pwncollege_api/v1/dojos/${init.dojo}/admins/promote`, "POST", "User has been promoted to admin.", confirm_msg = (form, params) => {
var user_name = form.find(`#name-for-${params["user_id"]}`)
Expand Down
17 changes: 17 additions & 0 deletions dojo_theme/templates/hacker.html
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,16 @@ <h3 class="d-block">
</h3>
{% endfor %}

{% if discord_member and user_privacy.show_discord %}
<h3 class="d-inline-block">
<span class="badge badge-secondary">
<img src="{{ url_for('views.themes', path='img/dojo/discord_logo.svg') }}"
style="width: 16px; height: 16px; margin-right: 5px;">
{{ discord_member.user.username }}
</span>
</h3>
{% endif %}

<div class="pt-3">
{% if user.website %}
<a href="{{ user.website }}" target="_blank" style="color: inherit;" rel="noopener">
Expand Down Expand Up @@ -74,12 +84,17 @@ <h3 class="d-block">
</a>
{% endif %}
</div>
{% if user_privacy.show_activity %}
<div id="activity-tracker" user-id="{{ user.id }}"></div>
{% endif %}
</div>
{% if user_privacy.show_activity %}
<script defer src="{{ url_for('views.themes', path='js/dojo/activity.js') }}"></script>
{% endif %}
{% block scripts %}
{% endblock %}

{% if user_privacy.show_solve_data %}
<div class="container">
{% for dojo in dojos if dojo_scores.user_ranks[user.id] and dojo_scores.user_ranks[user.id][dojo.id] %}
{% set rank = dojo_scores.user_ranks[user.id][dojo.id] %}
Expand Down Expand Up @@ -155,4 +170,6 @@ <h6>Time of First Successful Submission: {{user_solves[dojo.id][module.id][chall
</style>
<br>
{% endfor %}
</div>
{% endif %}
{% endblock %}
20 changes: 18 additions & 2 deletions dojo_theme/templates/module.html
Original file line number Diff line number Diff line change
Expand Up @@ -171,8 +171,24 @@ <h4 class="accordion-item-name challenge-name {{ active }}" data-challenge-index
</h4>
<span class="challenge-header-right">
{% if challenge_container_counts.get(challenge.id, 0) > 0 %}
<span class="total-hackers">
{{ challenge_container_counts.get(challenge.id, 0) }} hacking,
{% set active_users = challenge_active_users.get(challenge.id, []) %}
<span class="total-hackers-wrapper">
<span class="total-hackers">
{{ challenge_container_counts.get(challenge.id, 0) }} hacking,
</span>
{% if active_users %}
<div class="hacker-list-dropdown">
{% for user in active_users[:10] %}
<div class="hacker-entry">
<a href="{{ url_for('pwncollege_users.view_other', user_id=user.id) }}" class="hacker-link">{{ user.display_name }}</a>
<span class="hacker-duration">{{ user.duration }}</span>
</div>
{% endfor %}
{% if active_users|length > 10 %}
<span class="more-hackers">and {{ active_users|length - 10 }} more...</span>
{% endif %}
</div>
{% endif %}
</span>
{% endif %}
<span class="total-solves">
Expand Down
Loading