Skip to content

Commit 9ac0bbb

Browse files
authored
Fix issues with backup importing (CTFd#2092)
* Closes CTFd#2087 * Use `python manage.py import_ctf` instead of a new Process to import backups from the Admin Panel. * This avoids a number of issues with gevent and webserver forking/threading models. * Add `--delete_import_on_finish` to `python manage.py import_ctf` * Fix issue where `field_entries` table could not be imported when moving between MySQL and MariaDB
1 parent 90e81d7 commit 9ac0bbb

File tree

5 files changed

+89
-17
lines changed

5 files changed

+89
-17
lines changed

CTFd/themes/admin/templates/import.html

+15-7
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,17 @@ <h1>Import Status</h1>
2121
<p>
2222
<b>Import Error:</b> {{ import_error }}
2323
</p>
24+
<div class="alert alert-danger" role="alert">
25+
An error occurred during the import. Please try again.
26+
</div>
2427
{% else %}
2528
<p>
2629
<b>Current Status:</b> {{ import_status }}
2730
</p>
31+
<div class="alert alert-secondary" role="alert">
32+
Page will redirect upon completion. Refresh page to get latest status.<br>
33+
Page will automatically refresh every 5 seconds.
34+
</div>
2835
{% endif %}
2936
</div>
3037
</div>
@@ -33,18 +40,19 @@ <h1>Import Status</h1>
3340

3441
{% block scripts %}
3542
<script>
43+
// Reload every 5 seconds to poll import status
44+
setTimeout(function(){
45+
window.location.reload();
46+
}, 5000);
47+
3648
let start_time = "{{ start_time | tojson }}";
3749
let end_time = "{{ end_time | tojson }}";
3850
let start = document.getElementById("start-time");
3951
start.innerText = new Date(parseInt(start_time) * 1000);
40-
let end = document.getElementById("end-time");
41-
end.innerText = new Date(parseInt(end_time) * 1000);
4252

43-
// Reload every 5 seconds to poll import status
44-
if (!end_time) {
45-
setTimeout(function(){
46-
window.location.reload();
47-
}, 5000);
53+
if (end_time !== "null") {
54+
let end = document.getElementById("end-time");
55+
end.innerText = new Date(parseInt(end_time) * 1000);
4856
}
4957
</script>
5058
{% endblock %}

CTFd/utils/exports/__init__.py

+35-9
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,14 @@
22
import json
33
import os
44
import re
5+
import subprocess # nosec B404
6+
import sys
57
import tempfile
68
import zipfile
79
from io import BytesIO
8-
from multiprocessing import Process
10+
from pathlib import Path
911

1012
import dataset
11-
from flask import copy_current_request_context
1213
from flask import current_app as app
1314
from flask_migrate import upgrade as migration_upgrade
1415
from sqlalchemy.engine.url import make_url
@@ -24,6 +25,7 @@
2425
from CTFd.plugins.migrations import upgrade as plugin_upgrade
2526
from CTFd.utils import get_app_config, set_config, string_types
2627
from CTFd.utils.dates import unix_time
28+
from CTFd.utils.exports.databases import is_database_mariadb
2729
from CTFd.utils.exports.freeze import freeze_export
2830
from CTFd.utils.migrations import (
2931
create_database,
@@ -95,6 +97,10 @@ def set_status(val):
9597
cache.set(key="import_status", value=val, timeout=cache_timeout)
9698
print(val)
9799

100+
# Reset import cache keys and don't print these values
101+
cache.set(key="import_error", value=None, timeout=cache_timeout)
102+
cache.set(key="import_status", value=None, timeout=cache_timeout)
103+
98104
if not zipfile.is_zipfile(backup):
99105
set_error("zipfile.BadZipfile: zipfile is invalid")
100106
raise zipfile.BadZipfile
@@ -165,6 +171,7 @@ def set_status(val):
165171
sqlite = get_app_config("SQLALCHEMY_DATABASE_URI").startswith("sqlite")
166172
postgres = get_app_config("SQLALCHEMY_DATABASE_URI").startswith("postgres")
167173
mysql = get_app_config("SQLALCHEMY_DATABASE_URI").startswith("mysql")
174+
mariadb = is_database_mariadb()
168175

169176
if erase:
170177
set_status("erasing")
@@ -258,7 +265,7 @@ def insertion(table_filenames):
258265
table = side_db[table_name]
259266

260267
saved = json.loads(data)
261-
count = saved["count"]
268+
count = len(saved["results"])
262269
for i, entry in enumerate(saved["results"]):
263270
set_status(f"inserting {member} {i}/{count}")
264271
# This is a hack to get SQLite to properly accept datetime values from dataset
@@ -306,6 +313,23 @@ def insertion(table_filenames):
306313
if requirements and isinstance(requirements, string_types):
307314
entry["requirements"] = json.loads(requirements)
308315

316+
# From v3.1.0 to v3.5.0 FieldEntries could have been varying levels of JSON'ified strings.
317+
# For example "\"test\"" vs "test". This results in issues with importing backups between
318+
# databases. Specifically between MySQL and MariaDB. Because CTFd standardizes against MySQL
319+
# we need to have an edge case here.
320+
if member == "db/field_entries.json":
321+
value = entry.get("value")
322+
if value:
323+
try:
324+
# Attempt to convert anything to its original Python value
325+
entry["value"] = str(json.loads(value))
326+
except (json.JSONDecodeError, TypeError):
327+
pass
328+
finally:
329+
# Dump the value into JSON if its mariadb or skip the conversion if not mariadb
330+
if mariadb:
331+
entry["value"] = json.dumps(entry["value"])
332+
309333
try:
310334
table.insert(entry)
311335
except ProgrammingError:
@@ -413,9 +437,11 @@ def insertion(table_filenames):
413437

414438

415439
def background_import_ctf(backup):
416-
@copy_current_request_context
417-
def ctx_bridge():
418-
import_ctf(backup)
419-
420-
p = Process(target=ctx_bridge)
421-
p.start()
440+
# The manage.py script will delete the backup for us
441+
f = tempfile.NamedTemporaryFile(delete=False)
442+
backup.save(f.name)
443+
python = sys.executable # Get path of Python interpreter
444+
manage_py = Path(app.root_path).parent / "manage.py" # Path to manage.py
445+
subprocess.Popen( # nosec B603
446+
[python, manage_py, "import_ctf", "--delete_import_on_finish", f.name]
447+
)

CTFd/utils/exports/databases.py

+12
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
from sqlalchemy.exc import OperationalError
2+
3+
from CTFd.models import db
4+
5+
6+
def is_database_mariadb():
7+
try:
8+
result = db.session.execute("SELECT version()").fetchone()[0]
9+
mariadb = "mariadb" in result.lower()
10+
except OperationalError:
11+
mariadb = False
12+
return mariadb

CTFd/utils/exports/serializers.py

+20
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from decimal import Decimal
55

66
from CTFd.utils import string_types
7+
from CTFd.utils.exports.databases import is_database_mariadb
78

89

910
class JSONEncoder(json.JSONEncoder):
@@ -35,13 +36,15 @@ def wrap(self, result):
3536
return result
3637

3738
def close(self):
39+
mariadb = is_database_mariadb()
3840
for _path, result in self.buckets.items():
3941
result = self.wrap(result)
4042

4143
# Certain databases (MariaDB) store JSON as LONGTEXT.
4244
# Before emitting a file we should standardize to valid JSON (i.e. a dict)
4345
# See Issue #973
4446
for i, r in enumerate(result["results"]):
47+
# Handle JSON used in tables that use requirements
4548
data = r.get("requirements")
4649
if data:
4750
try:
@@ -50,5 +53,22 @@ def close(self):
5053
except ValueError:
5154
pass
5255

56+
# Handle JSON used in FieldEntries table
57+
if mariadb:
58+
if sorted(r.keys()) == [
59+
"field_id",
60+
"id",
61+
"team_id",
62+
"type",
63+
"user_id",
64+
"value",
65+
]:
66+
value = r.get("value")
67+
if value:
68+
try:
69+
result["results"][i]["value"] = json.loads(value)
70+
except ValueError:
71+
pass
72+
5373
data = json.dumps(result, cls=JSONEncoder, separators=(",", ":"))
5474
self.fileobj.write(data.encode("utf-8"))

manage.py

+7-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import datetime
22
import shutil
33

4+
from pathlib import Path
5+
46
from flask_migrate import MigrateCommand
57
from flask_script import Manager
68

@@ -71,10 +73,14 @@ def export_ctf(path=None):
7173

7274

7375
@manager.command
74-
def import_ctf(path):
76+
def import_ctf(path, delete_import_on_finish=False):
7577
with app.app_context():
7678
import_ctf_util(path)
7779

80+
if delete_import_on_finish:
81+
print(f"Deleting {path}")
82+
Path(path).unlink()
83+
7884

7985
if __name__ == "__main__":
8086
manager.run()

0 commit comments

Comments
 (0)