Files
CTFd/migrations/1_2_0_upgrade_2_0_0.py
Kevin Chung adc70fb320 3.0.0a1 (#1523)
Alpha release of CTFd v3. 

# 3.0.0a1 / 2020-07-01

**General**

- CTFd is now Python 3 only
- Render markdown with the CommonMark spec provided by `cmarkgfm`
- Render markdown stripped of any malicious JavaScript or HTML.
  - This is a significant change from previous versions of CTFd where any HTML content from an admin was considered safe.
- Inject `Config`, `User`, `Team`, `Session`, and `Plugin` globals into Jinja
- User sessions no longer store any user-specific attributes.
  - Sessions only store the user's ID, CSRF nonce, and an hmac of the user's password
  - This allows for session invalidation on password changes
- The user facing side of CTFd now has user and team searching
- GeoIP support now available for converting IP addresses to guessed countries

**Admin Panel**

- Use EasyMDE as an improved description/text editor for Markdown enabled fields.
- Media Library button now integrated into EasyMDE enabled fields
- VueJS now used as the underlying implementation for the Media Library
- Fix setting theme color in Admin Panel
- Green outline border has been removed from the Admin Panel

**API**

- Significant overhauls in API documentation provided by Swagger UI and Swagger json
- Make almost all API endpoints provide filtering and searching capabilities
- Change `GET /api/v1/config/<config_key>` to return structured data according to ConfigSchema

**Themes**

- Themes now have access to the `Configs` global which provides wrapped access to `get_config`.
  - For example, `{{ Configs.ctf_name }}` instead of `get_ctf_name()` or `get_config('ctf_name')`
- Themes must now specify a `challenge.html` which control how a challenge should look.
- The main library for charts has been changed from Plotly to Apache ECharts.
- Forms have been moved into wtforms for easier form rendering inside of Jinja.
  - From Jinja you can access forms via the Forms global i.e. `{{ Forms }}`
  - This allows theme developers to more easily re-use a form without having to copy-paste HTML.
- Themes can now provide a theme settings JSON blob which can be injected into the theme with `{{ Configs.theme_settings }}`
- Core theme now includes the challenge ID in location hash identifiers to always refer the right challenge despite duplicate names

**Plugins**

- Challenge plugins have changed in structure to better allow integration with themes and prevent obtrusive Javascript/XSS.
  - Challenge rendering now uses `challenge.html` from the provided theme.
  - Accessing the challenge view content is now provided by `/api/v1/challenges/<challenge_id>` in the `view` section. This allows for HTML to be properly sanitized and rendered by the server allowing CTFd to remove client side Jinja rendering.
  - `challenge.html` now specifies what's required and what's rendered by the theme. This allows the challenge plugin to avoid having to deal with aspects of the challenge besides the description and input.
  - A more complete migration guide will be provided when CTFd v3 leaves beta
- Display current attempt count in challenge view when max attempts is enabled
- `get_standings()`, `get_team_stanadings()`, `get_user_standings()` now has a fields keyword argument that allows for specificying additional fields that SQLAlchemy should return when building the response set.
  - Useful for gathering additional data when building scoreboard pages
- Flags can now control the message that is shown to the user by raising `FlagException`
- Fix `override_template()` functionality

**Deployment**

- Enable SQLAlchemy's `pool_pre_ping` by default to reduce the likelihood of database connection issues
- Mailgun email settings are now deprecated. Admins should move to SMTP email settings instead.
- Postgres is now considered a second class citizen in CTFd. It is tested against but not a main database backend. If you use Postgres, you are entirely on your own with regards to supporting CTFd.
- Docker image now uses Debian instead of Alpine. See https://github.com/CTFd/CTFd/issues/1215 for rationale.
- `docker-compose.yml` now uses a non-root user to connect to MySQL/MariaDB
- `config.py` should no longer be editting for configuration, instead edit `config.ini` or the environment variables in `docker-compose.yml`
2020-07-01 12:06:05 -04:00

276 lines
8.7 KiB
Python

from __future__ import print_function
import os
import sys
import dataset
from sqlalchemy_utils import drop_database
from CTFd import config, create_app
from CTFd.utils import string_types
# This is important to allow access to the CTFd application factory
sys.path.append(os.getcwd())
def cast_bool(value):
if value and value.isdigit():
return int(value)
elif value and isinstance(value, string_types):
if value.lower() == "true":
return True
elif value.lower() == "false":
return False
else:
return value
if __name__ == "__main__":
print("/*\\ Migrating your database to 2.0.0 can potentially lose data./*\\")
print(
"""/*\\ Please be sure to back up all data by:
* creating a CTFd export
* creating a dump of your actual database
* and backing up the CTFd source code directory"""
)
print("/*\\ CTFd maintainers are not responsible for any data loss! /*\\")
if input("Run database migrations (Y/N)").lower().strip() == "y":
pass
else:
print("/*\\ Aborting database migrations... /*\\")
print("/*\\ Exiting... /*\\")
exit(1)
db_url = config.Config.SQLALCHEMY_DATABASE_URI
old_data = {}
old_conn = dataset.connect(config.Config.SQLALCHEMY_DATABASE_URI)
tables = old_conn.tables
for table in tables:
old_data[table] = old_conn[table].all()
if "alembic_version" in old_data:
old_data.pop("alembic_version")
print("Current Tables:")
for table in old_data.keys():
print("\t", table)
old_conn.executable.close()
print("DROPPING DATABASE")
drop_database(db_url)
app = create_app()
new_conn = dataset.connect(config.Config.SQLALCHEMY_DATABASE_URI)
print("MIGRATING Challenges")
for challenge in old_data["challenges"]:
hidden = challenge.pop("hidden")
challenge["state"] = "hidden" if hidden else "visible"
new_conn["challenges"].insert(dict(challenge))
del old_data["challenges"]
print("MIGRATING Teams")
for team in old_data["teams"]:
admin = team.pop("admin")
team["type"] = "admin" if admin else "user"
team["hidden"] = bool(team.pop("banned"))
team["banned"] = False
team["verified"] = bool(team.pop("verified"))
new_conn["users"].insert(dict(team))
del old_data["teams"]
print("MIGRATING Pages")
for page in old_data["pages"]:
page["content"] = page.pop("html")
new_conn["pages"].insert(dict(page))
del old_data["pages"]
print("MIGRATING Keys")
for key in old_data["keys"]:
key["challenge_id"] = key.pop("chal")
key["content"] = key.pop("flag")
new_conn["flags"].insert(dict(key))
del old_data["keys"]
print("MIGRATING Tags")
for tag in old_data["tags"]:
tag["challenge_id"] = tag.pop("chal")
tag["value"] = tag.pop("tag")
new_conn["tags"].insert(dict(tag))
del old_data["tags"]
print("MIGRATING Files")
for f in old_data["files"]:
challenge_id = f.pop("chal")
if challenge_id:
f["challenge_id"] = challenge_id
f["type"] = "challenge"
else:
f["page_id"] = None
f["type"] = "page"
new_conn["files"].insert(dict(f))
del old_data["files"]
print("MIGRATING Hints")
for hint in old_data["hints"]:
hint["type"] = "standard"
hint["challenge_id"] = hint.pop("chal")
hint["content"] = hint.pop("hint")
new_conn["hints"].insert(dict(hint))
del old_data["hints"]
print("MIGRATING Unlocks")
for unlock in old_data["unlocks"]:
unlock["user_id"] = unlock.pop(
"teamid"
) # This is intentional as previous CTFds are effectively in user mode
unlock["target"] = unlock.pop("itemid")
unlock["type"] = unlock.pop("model")
new_conn["unlocks"].insert(dict(unlock))
del old_data["unlocks"]
print("MIGRATING Awards")
for award in old_data["awards"]:
award["user_id"] = award.pop(
"teamid"
) # This is intentional as previous CTFds are effectively in user mode
new_conn["awards"].insert(dict(award))
del old_data["awards"]
submissions = []
for solve in old_data["solves"]:
solve.pop("id") # ID of a solve doesn't really matter
solve["challenge_id"] = solve.pop("chalid")
solve["user_id"] = solve.pop("teamid")
solve["provided"] = solve.pop("flag")
solve["type"] = "correct"
solve["model"] = "solve"
submissions.append(solve)
for wrong_key in old_data["wrong_keys"]:
wrong_key.pop("id") # ID of a fail doesn't really matter.
wrong_key["challenge_id"] = wrong_key.pop("chalid")
wrong_key["user_id"] = wrong_key.pop("teamid")
wrong_key["provided"] = wrong_key.pop("flag")
wrong_key["type"] = "incorrect"
wrong_key["model"] = "wrong_key"
submissions.append(wrong_key)
submissions = sorted(submissions, key=lambda k: k["date"])
print("MIGRATING Solves & WrongKeys")
for submission in submissions:
model = submission.pop("model")
if model == "solve":
new_id = new_conn["submissions"].insert(dict(submission))
submission["id"] = new_id
new_conn["solves"].insert(dict(submission))
elif model == "wrong_key":
new_conn["submissions"].insert(dict(submission))
del old_data["solves"]
del old_data["wrong_keys"]
print("MIGRATING Tracking")
for tracking in old_data["tracking"]:
tracking["user_id"] = tracking.pop("team")
new_conn["tracking"].insert(dict(tracking))
del old_data["tracking"]
print("MIGRATING Config")
banned = ["ctf_version"]
workshop_mode = None
hide_scores = None
prevent_registration = None
view_challenges_unregistered = None
view_scoreboard_if_authed = None
challenge_visibility = "private"
registration_visibility = "public"
score_visibility = "public"
account_visibility = "public"
for c in old_data["config"]:
c.pop("id")
if c["key"] == "workshop_mode":
workshop_mode = cast_bool(c["value"])
elif c["key"] == "hide_scores":
hide_scores = cast_bool(c["value"])
elif c["key"] == "prevent_registration":
prevent_registration = cast_bool(c["value"])
elif c["key"] == "view_challenges_unregistered":
view_challenges_unregistered = cast_bool(c["value"])
elif c["key"] == "view_scoreboard_if_authed":
view_scoreboard_if_authed = cast_bool(c["value"])
if c["key"] not in banned:
new_conn["config"].insert(dict(c))
if workshop_mode:
score_visibility = "admins"
account_visibility = "admins"
if hide_scores:
score_visibility = "hidden"
if prevent_registration:
registration_visibility = "private"
if view_challenges_unregistered:
challenge_visibility = "public"
if view_scoreboard_if_authed:
score_visibility = "private"
new_conn["config"].insert({"key": "user_mode", "value": "users"})
new_conn["config"].insert(
{"key": "challenge_visibility", "value": challenge_visibility}
)
new_conn["config"].insert(
{"key": "registration_visibility", "value": registration_visibility}
)
new_conn["config"].insert({"key": "score_visibility", "value": score_visibility})
new_conn["config"].insert(
{"key": "account_visibility", "value": account_visibility}
)
del old_data["config"]
manual = []
not_created = []
print("MIGRATING extra tables")
for table in old_data.keys():
print("MIGRATING", table)
new_conn.create_table(table, primary_id=False)
data = old_data[table]
ran = False
for row in data:
new_conn[table].insert(dict(row))
ran = True
else: # We finished inserting
if ran:
manual.append(table)
if ran is False:
not_created.append(table)
print("Migration completed.")
print(
"The following tables require manual setting of primary keys and manual inspection"
)
for table in manual:
print("\t", table)
print(
"For example you can use the following commands if you know that the PRIMARY KEY for the table is `id`:"
)
for table in manual:
print("\t", "ALTER TABLE `{table}` ADD PRIMARY KEY(id)".format(table=table))
print(
"The following tables were not created because they were empty and must be manually recreated (e.g. app.db.create_all()"
)
for table in not_created:
print("\t", table)