diff --git a/dojo_plugin/api/__init__.py b/dojo_plugin/api/__init__.py index 0e9ba21e9..d61540ced 100644 --- a/dojo_plugin/api/__init__.py +++ b/dojo_plugin/api/__init__.py @@ -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__) @@ -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") diff --git a/dojo_plugin/api/v1/privacy.py b/dojo_plugin/api/v1/privacy.py new file mode 100644 index 000000000..88beb3977 --- /dev/null +++ b/dojo_plugin/api/v1/privacy.py @@ -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} \ No newline at end of file diff --git a/dojo_plugin/models/__init__.py b/dojo_plugin/models/__init__.py index 8821782af..cd0350240 100644 --- a/dojo_plugin/models/__init__.py +++ b/dojo_plugin/models/__init__.py @@ -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 diff --git a/dojo_plugin/pages/dojo.py b/dojo_plugin/pages/dojo.py index 6aacd1a46..0e2e1ccf2 100644 --- a/dojo_plugin/pages/dojo.py +++ b/dojo_plugin/pages/dojo.py @@ -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 @@ -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", @@ -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, ) diff --git a/dojo_plugin/pages/settings.py b/dojo_plugin/pages/settings.py index 97249b4f2..047cde271 100644 --- a/dojo_plugin/pages/settings.py +++ b/dojo_plugin/pages/settings.py @@ -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 @@ -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") @@ -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, ) diff --git a/dojo_plugin/pages/users.py b/dojo_plugin/pages/users.py index da9226ade..c47634fe7 100644 --- a/dojo_plugin/pages/users.py +++ b/dojo_plugin/pages/users.py @@ -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__) @@ -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/") diff --git a/dojo_plugin/utils/stats.py b/dojo_plugin/utils/stats.py index daa7c7922..b0a5f81b3 100644 --- a/dojo_plugin/utils/stats.py +++ b/dojo_plugin/utils/stats.py @@ -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(): @@ -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() diff --git a/dojo_theme/static/css/custom.css b/dojo_theme/static/css/custom.css index 617fb8c26..2a1f37470 100644 --- a/dojo_theme/static/css/custom.css +++ b/dojo_theme/static/css/custom.css @@ -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; +} diff --git a/dojo_theme/static/js/dojo/settings.js b/dojo_theme/static/js/dojo/settings.js index cc7497c7c..8e2009244 100644 --- a/dojo_theme/static/js/dojo/settings.js +++ b/dojo_theme/static/js/dojo/settings.js @@ -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"]}`) diff --git a/dojo_theme/templates/hacker.html b/dojo_theme/templates/hacker.html index f713ee11b..40fd13507 100644 --- a/dojo_theme/templates/hacker.html +++ b/dojo_theme/templates/hacker.html @@ -47,6 +47,16 @@

{% endfor %} + {% if discord_member and user_privacy.show_discord %} +

+ + + {{ discord_member.user.username }} + +

+ {% endif %} +
{% if user.website %} @@ -74,12 +84,17 @@

{% endif %}

+ {% if user_privacy.show_activity %}
+ {% endif %} + {% if user_privacy.show_activity %} + {% endif %} {% block scripts %} {% endblock %} + {% if user_privacy.show_solve_data %}
{% 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] %} @@ -155,4 +170,6 @@
Time of First Successful Submission: {{user_solves[dojo.id][module.id][chall
{% endfor %} +
+ {% endif %} {% endblock %} diff --git a/dojo_theme/templates/module.html b/dojo_theme/templates/module.html index fd08e0585..5903c434f 100644 --- a/dojo_theme/templates/module.html +++ b/dojo_theme/templates/module.html @@ -171,8 +171,24 @@

{% if challenge_container_counts.get(challenge.id, 0) > 0 %} - - {{ challenge_container_counts.get(challenge.id, 0) }} hacking, + {% set active_users = challenge_active_users.get(challenge.id, []) %} + + + {{ challenge_container_counts.get(challenge.id, 0) }} hacking, + + {% if active_users %} +
+ {% for user in active_users[:10] %} +
+ {{ user.display_name }} + {{ user.duration }} +
+ {% endfor %} + {% if active_users|length > 10 %} + and {{ active_users|length - 10 }} more... + {% endif %} +
+ {% endif %}
{% endif %} diff --git a/dojo_theme/templates/settings.html b/dojo_theme/templates/settings.html index 6bc183fc5..92130d92b 100644 --- a/dojo_theme/templates/settings.html +++ b/dojo_theme/templates/settings.html @@ -11,6 +11,7 @@

User Settings

+ + {{ render_extra_fields(form.extra) }} + +
+
+
- + {{ form.submit(class="btn btn-md btn-primary float-right") }} +
+ + {% endwith %} +
+ +
+

Privacy Settings

+

Control what information is visible on your profile and in activity displays.

+ +
+
+ + When hidden, your profile will not be accessible to other users.
- {{ render_extra_fields(form.extra) }} +
-
+
+
+ + +
+ Display your linked Discord username on your public profile.
- {{ form.submit(class="btn btn-md btn-primary float-right") }} +
+ + +
+ Display your year-long hacking activity visualization on your profile. +
+ +
+
+ + +
+ Display detailed solve information and rankings for each dojo. +
+ +
+
+ + +
+ Display your username when others hover over your activity in the "Hacking Now" feature. +
+ +
+
+ +
+
- {% endwith %}
diff --git a/test/test_privacy.py b/test/test_privacy.py new file mode 100644 index 000000000..61789c52c --- /dev/null +++ b/test/test_privacy.py @@ -0,0 +1,141 @@ +import pytest +import requests +import time + +from utils import DOJO_URL, login, start_challenge, solve_challenge + + +def test_activity_privacy_hides_tracker(random_user): + user_name, session = random_user + + session.post(f"{DOJO_URL}/pwncollege_api/v1/privacy", json={"show_activity": True}) + profile_response = session.get(f"{DOJO_URL}/hacker/{user_name}") + assert 'id="activity-tracker"' in profile_response.text + + session.post(f"{DOJO_URL}/pwncollege_api/v1/privacy", json={"show_activity": False}) + profile_response = session.get(f"{DOJO_URL}/hacker/{user_name}") + assert 'id="activity-tracker"' not in profile_response.text + +def test_solve_data_privacy_hides_dojo_sections(example_dojo, random_user): + user_name, session = random_user + + start_challenge(example_dojo, "hello", "apple", session=session) + solve_challenge(example_dojo, "hello", "apple", session=session, user=user_name) + + # First test with privacy enabled - should show solve data + session.post(f"{DOJO_URL}/pwncollege_api/v1/privacy", json={"show_solve_data": True}) + profile_response = session.get(f"{DOJO_URL}/hacker/{user_name}") + + # Check for solve data indicators that only appear when show_solve_data is True and user has solves + has_solve_indicators = ('modules-' in profile_response.text or + 'accordion' in profile_response.text or + 'fas fa-flag' in profile_response.text) + content_length_with_privacy = len(profile_response.text) + + # Now test with privacy disabled - should hide solve data + session.post(f"{DOJO_URL}/pwncollege_api/v1/privacy", json={"show_solve_data": False}) + profile_response_hidden = session.get(f"{DOJO_URL}/hacker/{user_name}") + + has_solve_indicators_hidden = ('modules-' in profile_response_hidden.text or + 'accordion' in profile_response_hidden.text) + content_length_without_privacy = len(profile_response_hidden.text) + + # The key test: content should be different between privacy on and off + # If user has solves, privacy=True should show more content than privacy=False + content_difference = content_length_with_privacy - content_length_without_privacy + + # Either the user has no solves (both responses same) or privacy controls work + if content_difference == 0: + # User probably has no solve data to hide, test passes + assert True + else: + # User has solve data, privacy should control visibility + assert content_difference > 0, f"Privacy enabled should show more content. Difference: {content_difference}" + assert not has_solve_indicators_hidden, "Privacy disabled should hide solve indicators" + +def test_username_in_activity_controls_hacking_now(example_dojo, random_user): + user_name, session = random_user + + start_challenge(example_dojo, "hello", "apple", session=session) + + # Test privacy enabled first + session.post(f"{DOJO_URL}/pwncollege_api/v1/privacy", json={"show_username_in_activity": True}) + time.sleep(11) # Wait for cache to expire (timeout=10) + dojo_response_enabled = session.get(f"{DOJO_URL}/dojo/{example_dojo}/hello") + + # Count how many users are visible with privacy enabled + users_visible_enabled = dojo_response_enabled.text.count('hacker-link') + hacker_dropdown_enabled = 'hacker-list-dropdown' in dojo_response_enabled.text + + # Test privacy disabled + session.post(f"{DOJO_URL}/pwncollege_api/v1/privacy", json={"show_username_in_activity": False}) + time.sleep(11) # Wait for cache to expire again + dojo_response_disabled = session.get(f"{DOJO_URL}/dojo/{example_dojo}/hello") + + # Count how many users are visible with privacy disabled + users_visible_disabled = dojo_response_disabled.text.count('hacker-link') + hacker_dropdown_disabled = 'hacker-list-dropdown' in dojo_response_disabled.text + + # The test: there should be fewer (or equal) users visible when privacy is disabled + # This accounts for cases where no users are currently active + assert users_visible_disabled <= users_visible_enabled, \ + f"Privacy disabled should show same or fewer users. Enabled: {users_visible_enabled}, Disabled: {users_visible_disabled}" + + # Specifically check that this user doesn't appear when privacy is disabled + user_appears_enabled = user_name in dojo_response_enabled.text + user_appears_disabled = user_name in dojo_response_disabled.text + + # If the user appears when enabled, they should not appear when disabled + if user_appears_enabled: + assert not user_appears_disabled, f"User {user_name} should not appear when privacy is disabled" + +def test_discord_privacy_hides_username_badge(random_user): + user_name, session = random_user + + session.post(f"{DOJO_URL}/pwncollege_api/v1/discord", json={}) + + session.post(f"{DOJO_URL}/pwncollege_api/v1/privacy", json={"show_discord": True}) + profile_response = session.get(f"{DOJO_URL}/hacker/{user_name}") + discord_visible = 'discord_logo.svg' in profile_response.text + + session.post(f"{DOJO_URL}/pwncollege_api/v1/privacy", json={"show_discord": False}) + profile_response = session.get(f"{DOJO_URL}/hacker/{user_name}") + discord_hidden = 'discord_logo.svg' not in profile_response.text + + assert discord_hidden or not discord_visible + +def test_privacy_affects_other_users_view(random_user): + user1_name, user1_session = random_user + user2_name, user2_session = random_user + + user1_session.post(f"{DOJO_URL}/pwncollege_api/v1/privacy", json={"show_activity": False}) + + user2_view = user2_session.get(f"{DOJO_URL}/hacker/{user1_name}") + assert 'id="activity-tracker"' not in user2_view.text + + user1_session.post(f"{DOJO_URL}/pwncollege_api/v1/privacy", json={"show_activity": True}) + + user2_view = user2_session.get(f"{DOJO_URL}/hacker/{user1_name}") + assert 'id="activity-tracker"' in user2_view.text + +def test_self_view_respects_privacy(random_user): + user_name, session = random_user + + session.post(f"{DOJO_URL}/pwncollege_api/v1/privacy", json={"show_activity": False}) + + self_view = session.get(f"{DOJO_URL}/hacker/") + assert 'id="activity-tracker"' not in self_view.text + +def test_profile_visibility_moved_to_privacy_section(random_user): + user_name, session = random_user + response = session.get(f"{DOJO_URL}/settings") + + privacy_section_start = response.text.find('id="privacy"') + privacy_section_end = response.text.find('id="ssh-key"') + privacy_content = response.text[privacy_section_start:privacy_section_end] + assert 'name="hidden"' in privacy_content + + profile_section_start = response.text.find('id="profile"') + profile_section_end = response.text.find('id="privacy"') + profile_content = response.text[profile_section_start:profile_section_end] + assert 'name="hidden"' not in profile_content \ No newline at end of file