Skip to content

Commit 2cf0aec

Browse files
authored
Add API endpoint to download exports (CTFd#2547)
* Add `POST /api/v1/exports/raw` to download CTFd and CSV exports via the API
1 parent 40b8813 commit 2cf0aec

File tree

4 files changed

+99
-2
lines changed

4 files changed

+99
-2
lines changed

CTFd/api/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from CTFd.api.v1.challenges import challenges_namespace
77
from CTFd.api.v1.comments import comments_namespace
88
from CTFd.api.v1.config import configs_namespace
9+
from CTFd.api.v1.exports import exports_namespace
910
from CTFd.api.v1.files import files_namespace
1011
from CTFd.api.v1.flags import flags_namespace
1112
from CTFd.api.v1.hints import hints_namespace
@@ -75,3 +76,4 @@
7576
CTFd_API_v1.add_namespace(comments_namespace, "/comments")
7677
CTFd_API_v1.add_namespace(shares_namespace, "/shares")
7778
CTFd_API_v1.add_namespace(brackets_namespace, "/brackets")
79+
CTFd_API_v1.add_namespace(exports_namespace, "/exports")

CTFd/api/v1/exports.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
from datetime import datetime as DateTime
2+
3+
from flask import request, send_file
4+
from flask_restx import Namespace, Resource
5+
6+
from CTFd.utils.config import ctf_name
7+
from CTFd.utils.csv import dump_csv
8+
from CTFd.utils.decorators import admins_only, ratelimit
9+
from CTFd.utils.exports import export_ctf as export_ctf_util
10+
11+
exports_namespace = Namespace("exports", description="Endpoint to retrieve Exports")
12+
13+
14+
@exports_namespace.route("/raw")
15+
class ExportList(Resource):
16+
@admins_only
17+
@ratelimit(method="POST", limit=10, interval=60)
18+
def post(self):
19+
req = request.get_json()
20+
export_type = req.get("type", "_")
21+
export_args = req.get("args", {})
22+
23+
day = DateTime.now().strftime("%Y-%m-%d_%T")
24+
if export_type == "csv":
25+
table = export_args.get("table")
26+
if not table:
27+
return {
28+
"success": False,
29+
"errors": {"args": "Missing table to export"},
30+
}, 400
31+
output = dump_csv(name=table)
32+
return send_file(
33+
output,
34+
as_attachment=True,
35+
max_age=-1,
36+
download_name=f"{ctf_name()}-{table}-{day}.csv",
37+
)
38+
else:
39+
backup = export_ctf_util()
40+
full_name = f"{ctf_name()}.{day}.zip"
41+
return send_file(
42+
backup,
43+
cache_timeout=-1,
44+
as_attachment=True,
45+
attachment_filename=full_name,
46+
)

CTFd/utils/exports/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import sys
77
import tempfile
88
import zipfile
9-
from io import BytesIO
9+
from io import BytesIO, StringIO
1010
from pathlib import Path
1111

1212
import dataset
@@ -62,7 +62,7 @@ def export_ctf():
6262
"results": [{"version_num": get_current_revision()}],
6363
"meta": {},
6464
}
65-
result_file = BytesIO()
65+
result_file = StringIO()
6666
json.dump(result, result_file)
6767
result_file.seek(0)
6868
backup_zip.writestr("db/alembic_version.json", result_file.read())

tests/api/v1/test_exports.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
from tests.helpers import (
2+
create_ctfd,
3+
destroy_ctfd,
4+
gen_challenge,
5+
login_as_user,
6+
register_user,
7+
)
8+
9+
10+
def test_api_export_csv():
11+
app = create_ctfd()
12+
with app.app_context():
13+
gen_challenge(app.db)
14+
data = {
15+
"type": "csv",
16+
"args": {"table": "challenges"},
17+
}
18+
19+
with login_as_user(app, name="admin", password="password") as client:
20+
r = client.post("/api/v1/exports/raw", json=data)
21+
assert r.status_code == 200
22+
assert r.headers["Content-Type"].startswith("text/csv")
23+
assert "chal_name" in r.get_data(as_text=True)
24+
25+
# Test that regular users cannot access the endpoint
26+
register_user(app)
27+
with login_as_user(app) as client:
28+
response = client.post("/api/v1/exports/raw", json=data)
29+
assert response.status_code == 403
30+
destroy_ctfd(app)
31+
32+
33+
def test_api_export():
34+
app = create_ctfd()
35+
with app.app_context():
36+
gen_challenge(app.db)
37+
data = {}
38+
39+
with login_as_user(app, name="admin", password="password") as client:
40+
r = client.post("/api/v1/exports/raw", json=data)
41+
assert r.status_code == 200
42+
assert r.headers["Content-Type"].startswith("application/zip")
43+
44+
# Test that regular users cannot access the endpoint
45+
register_user(app)
46+
with login_as_user(app) as client:
47+
response = client.post("/api/v1/exports/raw", json=data)
48+
assert response.status_code == 403
49+
destroy_ctfd(app)

0 commit comments

Comments
 (0)