Skip to content

Commit 609e940

Browse files
authored
Add ability to store manage media files locally and reference their URLs in Pages (#156)
* Add `ctf media add`, `ctf media rm`, `ctf media url` commands * Allows ctfcli repos to manage files locally and reference the actual server URLs of media files in Pages * Adds concept of replacing placeholders like `{{ media/ctfd.png }}` with the actual URL on the server
1 parent 8fa7f3d commit 609e940

File tree

5 files changed

+114
-2
lines changed

5 files changed

+114
-2
lines changed

ctfcli/__main__.py

+5
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from ctfcli.cli.challenges import ChallengeCommand
1313
from ctfcli.cli.config import ConfigCommand
1414
from ctfcli.cli.instance import InstanceCommand
15+
from ctfcli.cli.media import MediaCommand
1516
from ctfcli.cli.pages import PagesCommand
1617
from ctfcli.cli.plugins import PluginsCommand
1718
from ctfcli.cli.templates import TemplatesCommand
@@ -111,6 +112,9 @@ def challenge(self):
111112
def pages(self):
112113
return COMMANDS.get("pages")
113114

115+
def media(self):
116+
return COMMANDS.get("media")
117+
114118
def plugins(self):
115119
return COMMANDS.get("plugins")
116120

@@ -125,6 +129,7 @@ def templates(self):
125129
"plugins": PluginsCommand(),
126130
"templates": TemplatesCommand(),
127131
"instance": InstanceCommand(),
132+
"media": MediaCommand(),
128133
"cli": CTFCLI(),
129134
}
130135

ctfcli/cli/media.py

+80
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import os
2+
3+
import click
4+
5+
from ctfcli.core.api import API
6+
from ctfcli.core.config import Config
7+
8+
9+
class MediaCommand:
10+
def add(self, path):
11+
"""Add local media file to config file and remote instance"""
12+
config = Config()
13+
if config.config.has_section("media") is False:
14+
config.config.add_section("media")
15+
16+
api = API()
17+
18+
new_file = ("file", open(path, mode="rb"))
19+
filename = os.path.basename(path)
20+
location = f"media/{filename}"
21+
file_payload = {
22+
"type": "page",
23+
"location": location,
24+
}
25+
26+
# Specifically use data= here to send multipart/form-data
27+
r = api.post("/api/v1/files", files=[new_file], data=file_payload)
28+
r.raise_for_status()
29+
resp = r.json()
30+
server_location = resp["data"][0]["location"]
31+
32+
# Close the file handle
33+
new_file[1].close()
34+
35+
config.config.set("media", location, f"/files/{server_location}")
36+
37+
with open(config.config_path, "w+") as f:
38+
config.write(f)
39+
40+
def rm(self, path):
41+
"""Remove local media file from remote server and local config"""
42+
config = Config()
43+
api = API()
44+
45+
local_location = config["media"][path]
46+
47+
remote_files = api.get("/api/v1/files?type=page").json()["data"]
48+
for remote_file in remote_files:
49+
if f"/files/{remote_file['location']}" == local_location:
50+
# Delete file from server
51+
r = api.delete(f"/api/v1/files/{remote_file['id']}")
52+
r.raise_for_status()
53+
54+
# Update local config file
55+
del config["media"][path]
56+
with open(config.config_path, "w+") as f:
57+
config.write(f)
58+
59+
def url(self, path):
60+
"""Get server URL for a file key"""
61+
config = Config()
62+
api = API()
63+
64+
if config.config.has_section("media") is False:
65+
config.config.add_section("media")
66+
67+
try:
68+
location = config["media"][path]
69+
except KeyError:
70+
click.secho(f"Could not locate local media '{path}'", fg="red")
71+
return 1
72+
73+
remote_files = api.get("/api/v1/files?type=page").json()["data"]
74+
for remote_file in remote_files:
75+
if f"/files/{remote_file['location']}" == location:
76+
base_url = config["config"]["url"]
77+
base_url = base_url.rstrip("/")
78+
return f"{base_url}{location}"
79+
click.secho(f"Could not locate remote media '{path}'", fg="red")
80+
return 1

ctfcli/core/media.py

+15
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
from ctfcli.core.config import Config
2+
from ctfcli.utils.tools import safe_format
3+
4+
5+
class Media:
6+
@staticmethod
7+
def replace_placeholders(content: str) -> str:
8+
config = Config()
9+
try:
10+
section = config["media"]
11+
except KeyError:
12+
section = []
13+
for m in section:
14+
content = safe_format(content, items={m: config["media"][m]})
15+
return content

ctfcli/core/page.py

+5-2
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
InvalidPageConfiguration,
1818
InvalidPageFormat,
1919
)
20+
from ctfcli.core.media import Media
2021

2122
PAGE_FORMATS = {
2223
".md": "markdown",
@@ -85,8 +86,8 @@ def _get_data_by_path(self) -> Optional[Dict]:
8586

8687
with open(self.page_path, "r") as page_file:
8788
page_data = frontmatter.load(page_file)
88-
89-
return {**page_data.metadata, "content": page_data.content}
89+
content = Media.replace_placeholders(page_data.content)
90+
return {**page_data.metadata, "content": content}
9091

9192
def _get_data_by_id(self) -> Optional[Dict]:
9293
r = self.api.get(f"/api/v1/pages/{self.page_id}")
@@ -173,6 +174,8 @@ def get_format(ext) -> str:
173174

174175
@staticmethod
175176
def get_format_extension(fmt) -> str:
177+
if fmt is None:
178+
return ".md"
176179
for supported_ext, supported_fmt in PAGE_FORMATS.items():
177180
if fmt == supported_fmt:
178181
return supported_ext

ctfcli/utils/tools.py

+9
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import re
12
import string
23

34

@@ -21,3 +22,11 @@ def strings(filename, min_length=4):
2122

2223
if len(result) >= min_length: # catch result at EOF
2324
yield result
25+
26+
27+
def safe_format(fmt, items):
28+
"""
29+
Function that safely formats strings with arbitrary potentially user-supplied format strings
30+
Looks for interpolation placeholders like {target} or {{ target }}
31+
"""
32+
return re.sub(r"\{?\{([^{}]*)\}\}?", lambda m: items.get(m.group(1).strip(), m.group(0)), fmt)

0 commit comments

Comments
 (0)