Skip to content

Commit 474cba9

Browse files
added ability for users to delete own challenges, added jinja translation customization
1 parent d5f6f3b commit 474cba9

29 files changed

Lines changed: 360 additions & 1112 deletions

CTFd/plugins/LuaUtils/__init__.py

Lines changed: 89 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,30 @@
11
import difflib
22
import functools
3+
import json
34
import os
45
import re
56

67
from flask import current_app, request, url_for
8+
from flask_babel import gettext, ngettext
79

810
from CTFd.cache import cache
11+
from CTFd.constants.languages import Languages
912
from CTFd.utils import _get_asset_json, get_asset_json, get_config, set_config
1013
from CTFd.utils.decorators import admins_only
1114
from CTFd.utils.helpers import markup
1215

1316

1417
def load(app):
1518
cache.delete_memoized(_get_asset_json)
19+
20+
# Auto-register translations from all plugins
21+
plugins_dir = os.path.join(current_app.root_path, "plugins")
22+
for plugin_name in os.listdir(plugins_dir):
23+
plugin_dir = os.path.join(plugins_dir, plugin_name)
24+
if os.path.isdir(plugin_dir):
25+
translations_file = os.path.join(plugin_dir, 'translations.json')
26+
if os.path.exists(translations_file):
27+
register_translations(app, plugin_name, plugin_dir)
1628

1729
@app.route("/admin/LuaUtils/config/<configType>", methods=["GET"])
1830
@admins_only
@@ -33,7 +45,6 @@ def set_inlines(configType):
3345
set_config(key, value)
3446
return {"success": True}
3547

36-
3748
return
3849

3950
class _LuaAsset():
@@ -166,3 +177,80 @@ def modify_p(p_match):
166177

167178
# 4. Reconstruct the full document
168179
return text[:container_match.start()] + prefix + new_inner_content + suffix + text[container_match.end():]
180+
181+
182+
def load_plugin_translations(plugin_dir):
183+
"""Load translations from a plugin's translations.json"""
184+
translations_path = os.path.join(plugin_dir, 'translations.json')
185+
try:
186+
with open(translations_path, 'r', encoding='utf-8') as f:
187+
return json.load(f)
188+
except FileNotFoundError:
189+
return {}
190+
191+
def register_translations(app, plugin_name, plugin_dir):
192+
"""General-purpose method to integrate plugin translations into Jinja.
193+
194+
Args:
195+
app: Flask application
196+
plugin_name: Plugin name (e.g., 'LuaUtils', 'CustomPlugin')
197+
plugin_dir: Absolute path to plugin directory
198+
199+
Usage:
200+
from CTFd.plugins.LuaUtils import register_translations
201+
register_translations(app, 'MyPlugin', os.path.dirname(__file__))
202+
"""
203+
from flask_babel import get_locale
204+
from flask_babel import gettext as babel_gettext
205+
from flask_babel import ngettext as babel_ngettext
206+
207+
translations = load_plugin_translations(plugin_dir)
208+
if not translations:
209+
return
210+
211+
# Store translations in config for database persistence
212+
for lang, terms in translations.items():
213+
for term, translation in terms.items():
214+
key = f"{plugin_name.lower()}_translation_{lang}_{term}"
215+
set_config(key, translation)
216+
217+
# Wrap Flask-Babel's gettext functions to check plugin translations first
218+
if not hasattr(app, '_luautils_gettext_wrapped'):
219+
def wrapped_gettext(s):
220+
"""Check plugin translations first, then fall back to Flask-Babel."""
221+
for check_plugin in getattr(app, '_registered_plugins', []):
222+
try:
223+
lang = str(get_locale())
224+
config_key = f"{check_plugin.lower()}_translation_{lang}_{s}"
225+
translation = get_config(config_key)
226+
if translation:
227+
return translation
228+
except Exception:
229+
pass
230+
# Fall back to Flask-Babel's default
231+
return babel_gettext(s)
232+
233+
def wrapped_ngettext(s, p, n):
234+
"""Check plugin translations for plurals, then fall back to Flask-Babel."""
235+
msg = s if n == 1 else p
236+
for check_plugin in getattr(app, '_registered_plugins', []):
237+
try:
238+
lang = str(get_locale())
239+
config_key = f"{check_plugin.lower()}_translation_{lang}_{msg}"
240+
translation = get_config(config_key)
241+
if translation:
242+
return translation
243+
except Exception:
244+
pass
245+
# Fall back to Flask-Babel's default
246+
return babel_ngettext(s, p, n)
247+
248+
# Install our wrapped callables
249+
app.jinja_env.install_gettext_callables(wrapped_gettext, wrapped_ngettext, newstyle=True)
250+
app._luautils_gettext_wrapped = True
251+
252+
# Track registered plugins
253+
if not hasattr(app, '_registered_plugins'):
254+
app._registered_plugins = []
255+
if plugin_name not in app._registered_plugins:
256+
app._registered_plugins.append(plugin_name)
Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Script to translate a keyword into all CTFd available languages and add to a plugin's translations.json
4+
5+
Usage:
6+
python translate_keyword.py <keyword> <plugin_name>
7+
8+
Examples:
9+
python translate_keyword.py "Challenge Created" MyPlugin
10+
python translate_keyword.py "Challenge Created" /path/to/plugin # Full path also works
11+
"""
12+
13+
import argparse
14+
import json
15+
import os
16+
import sys
17+
18+
# Try to import translation library
19+
try:
20+
from deep_translator import GoogleTranslator
21+
HAS_TRANSLATOR = True
22+
except ImportError:
23+
HAS_TRANSLATOR = False
24+
25+
# Import CTFd's language constants
26+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '../..'))
27+
28+
29+
Languages = {
30+
"en": "English",
31+
"de": "Deutsch",
32+
"pl": "Polski",
33+
"es": "Español",
34+
"ar": "اَلْعَرَبِيَّةُ",
35+
"zh_CN": "简体中文",
36+
"zh_TW": "繁體中文",
37+
"fr": "Français",
38+
"ko": "한국어",
39+
"ru": "русский язык",
40+
"pt_BR": "Português do Brasil",
41+
"sk": "Slovenský jazyk",
42+
"ja": "日本語",
43+
"it": "Italiano",
44+
"vi": "tiếng Việt",
45+
"ca": "Català",
46+
"el": "Ελληνικά",
47+
"fi": "Suomi",
48+
"ro": "Română",
49+
"sl": "Slovenščina",
50+
"sv": "Svenska",
51+
"he": "עברית",
52+
"uz": "oʻzbekcha",
53+
}
54+
55+
56+
def translate_keyword(keyword, target_lang):
57+
"""Translate keyword to target language using available service."""
58+
try:
59+
translator = GoogleTranslator(source='auto', target=target_lang)
60+
result = translator.translate(keyword)
61+
return result
62+
except Exception as e:
63+
print(f"Warning: Translation failed for {target_lang}: {e}")
64+
return None
65+
66+
67+
68+
def load_translations_file(plugin_dir):
69+
"""Load existing translations from plugin's translations.json"""
70+
translations_path = os.path.join(plugin_dir, 'translations.json')
71+
if os.path.exists(translations_path):
72+
try:
73+
with open(translations_path, 'r', encoding='utf-8') as f:
74+
return json.load(f)
75+
except json.JSONDecodeError:
76+
return {}
77+
return {}
78+
79+
80+
def resolve_plugin_dir(plugin_input):
81+
"""Resolve plugin name or path to absolute plugin directory.
82+
83+
Args:
84+
plugin_input: Either a plugin name (e.g., 'MyPlugin') or full path
85+
86+
Returns:
87+
Absolute path to plugin directory
88+
"""
89+
# If it's an absolute path or looks like a path (contains / or \), use it as-is
90+
if os.path.isabs(plugin_input) or '/' in plugin_input or '\\' in plugin_input:
91+
return os.path.abspath(plugin_input)
92+
93+
# Otherwise, treat it as a plugin name and resolve relative to CTFd/plugins/
94+
luautils_dir = os.path.dirname(__file__)
95+
plugins_dir = os.path.dirname(luautils_dir)
96+
plugin_dir = os.path.join(plugins_dir, plugin_input)
97+
return os.path.abspath(plugin_dir)
98+
99+
100+
def save_translations_file(plugin_dir, translations):
101+
"""Save translations to plugin's translations.json"""
102+
translations_path = os.path.join(plugin_dir, 'translations.json')
103+
os.makedirs(plugin_dir, exist_ok=True)
104+
with open(translations_path, 'w', encoding='utf-8') as f:
105+
json.dump(translations, f, ensure_ascii=False, indent=2)
106+
107+
108+
def main():
109+
parser = argparse.ArgumentParser(
110+
description='Translate a keyword into all CTFd available languages'
111+
)
112+
parser.add_argument('keyword', help='The keyword to translate')
113+
parser.add_argument('plugin_name', help='Plugin name (e.g., MyPlugin) or full path')
114+
parser.add_argument(
115+
'--key',
116+
help='Translation key (defaults to lowercase keyword with underscores)'
117+
)
118+
parser.add_argument(
119+
'--no-save',
120+
action='store_true',
121+
help='Print translations without saving'
122+
)
123+
124+
args = parser.parse_args()
125+
126+
keyword = args.keyword
127+
plugin_input = args.plugin_name
128+
translation_key = args.key or keyword.lower().replace(' ', '_')
129+
130+
# Resolve plugin directory
131+
plugin_dir = resolve_plugin_dir(plugin_input)
132+
133+
# Validate plugin directory
134+
if not os.path.isdir(plugin_dir):
135+
print(f"Error: Plugin directory not found: {plugin_dir}")
136+
sys.exit(1)
137+
138+
# Check if translator is available
139+
if not HAS_TRANSLATOR:
140+
print("Error: deep-translator is required but not installed.")
141+
print("Install with: pip install deep-translator")
142+
sys.exit(1)
143+
144+
translations = {}
145+
failed_langs = []
146+
147+
# Translate to each language
148+
for lang_code, lang_name in Languages.items():
149+
if lang_code == 'en':
150+
# English is the source, use the keyword as-is
151+
translation = keyword
152+
else:
153+
translation = translate_keyword(keyword, lang_code)
154+
if translation is None:
155+
failed_langs.append(lang_code)
156+
continue
157+
158+
if lang_code not in translations:
159+
translations[lang_code] = {}
160+
translations[lang_code][translation_key] = translation
161+
162+
if failed_langs:
163+
print(f"Warning: Failed to translate to: {', '.join(failed_langs)}")
164+
165+
# Save if not --no-save
166+
if not args.no_save:
167+
# Load existing translations and merge
168+
existing = load_translations_file(plugin_dir)
169+
170+
# Merge new translations with existing
171+
for lang_code, new_terms in translations.items():
172+
if lang_code not in existing:
173+
existing[lang_code] = {}
174+
existing[lang_code].update(new_terms)
175+
176+
save_translations_file(plugin_dir, existing)
177+
print(f"Saved to {os.path.join(plugin_dir, 'translations.json')}")
178+
179+
if __name__ == '__main__':
180+
main()

CTFd/plugins/userchallenge/api_calls/challenges.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
)
2929
from CTFd.utils.dates import ctf_ended
3030
from CTFd.utils.decorators import admins_only
31+
from CTFd.utils.logging import log
3132
from CTFd.utils.security.signing import serialize
3233
from CTFd.utils.user import authed, get_current_team, get_current_user, is_admin
3334

@@ -327,7 +328,7 @@ def idchallget(challenge_id):
327328

328329
return {"success": True, "data": response}
329330

330-
@admins_only
331+
331332
@ReadOnly
332333
def delete_userchallenge(challenge_id):
333334
if request.method == "DELETE":
@@ -340,6 +341,20 @@ def delete_userchallenge(challenge_id):
340341

341342
#run_after_route(app,'api.challenges_challenge',delete_userchallenge)
342343

344+
345+
@app.route('/userchallenge/api/challenges/<challenge_id>',methods=['DELETE'])
346+
@userChallenge_allowed
347+
@ReadOnly
348+
def delete_challenge(challenge_id):
349+
delete_userchallenge(challenge_id)
350+
challenge = Challenges.query.filter_by(id=challenge_id).first_or_404()
351+
chal_class = get_chal_class(challenge.type)
352+
chal_class.delete(challenge)
353+
354+
clear_standings()
355+
clear_challenges()
356+
return {"success": True}
357+
343358
@app.route('/userchallenge/challenges/preview/<challenge_id>')
344359
@userChallenge_allowed
345360
def render_preview(challenge_id):

CTFd/plugins/userchallenge/assets/js/userChallenge.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,7 @@ $(() => {
165165
})
166166
.then(function (response) {
167167
if (response.success) {
168-
window.location = CTFd.config.urlRoot + "/admin/challenges";
168+
window.location = CTFd.config.urlRoot + "/userchallenge/challenges";
169169
}
170170
});
171171
},

0 commit comments

Comments
 (0)