756 generic data fields (#1608)

* Adds the ability to add custom user fields for registration/profile settings
* Admins can create fields that users can optionally edit 
* Works on #756
This commit is contained in:
Kevin Chung
2020-08-19 20:18:37 -04:00
committed by GitHub
parent 0bc58d5af1
commit fb454b8262
60 changed files with 1715 additions and 51 deletions

View File

@@ -8,8 +8,9 @@ from CTFd.api.v1.helpers.schemas import sqlalchemy_to_pydantic
from CTFd.api.v1.schemas import APIDetailedSuccessResponse, APIListSuccessResponse from CTFd.api.v1.schemas import APIDetailedSuccessResponse, APIListSuccessResponse
from CTFd.cache import clear_config, clear_standings from CTFd.cache import clear_config, clear_standings
from CTFd.constants import RawEnum from CTFd.constants import RawEnum
from CTFd.models import Configs, db from CTFd.models import Configs, Fields, db
from CTFd.schemas.config import ConfigSchema from CTFd.schemas.config import ConfigSchema
from CTFd.schemas.fields import FieldSchema
from CTFd.utils import set_config from CTFd.utils import set_config
from CTFd.utils.decorators import admins_only from CTFd.utils.decorators import admins_only
from CTFd.utils.helpers.models import build_model_filters from CTFd.utils.helpers.models import build_model_filters
@@ -189,3 +190,89 @@ class Config(Resource):
clear_standings() clear_standings()
return {"success": True} return {"success": True}
@configs_namespace.route("/fields")
class FieldList(Resource):
@admins_only
@validate_args(
{
"type": (str, None),
"q": (str, None),
"field": (RawEnum("FieldFields", {"description": "description"}), None),
},
location="query",
)
def get(self, query_args):
q = query_args.pop("q", None)
field = str(query_args.pop("field", None))
filters = build_model_filters(model=Fields, query=q, field=field)
fields = Fields.query.filter_by(**query_args).filter(*filters).all()
schema = FieldSchema(many=True)
response = schema.dump(fields)
if response.errors:
return {"success": False, "errors": response.errors}, 400
return {"success": True, "data": response.data}
@admins_only
def post(self):
req = request.get_json()
schema = FieldSchema()
response = schema.load(req, session=db.session)
if response.errors:
return {"success": False, "errors": response.errors}, 400
db.session.add(response.data)
db.session.commit()
response = schema.dump(response.data)
db.session.close()
return {"success": True, "data": response.data}
@configs_namespace.route("/fields/<field_id>")
class Field(Resource):
@admins_only
def get(self, field_id):
field = Fields.query.filter_by(id=field_id).first_or_404()
schema = FieldSchema()
response = schema.dump(field)
if response.errors:
return {"success": False, "errors": response.errors}, 400
return {"success": True, "data": response.data}
@admins_only
def patch(self, field_id):
field = Fields.query.filter_by(id=field_id).first_or_404()
schema = FieldSchema()
req = request.get_json()
response = schema.load(req, session=db.session, instance=field)
if response.errors:
return {"success": False, "errors": response.errors}, 400
db.session.commit()
response = schema.dump(response.data)
db.session.close()
return {"success": True, "data": response.data}
@admins_only
def delete(self, field_id):
field = Fields.query.filter_by(id=field_id).first_or_404()
db.session.delete(field)
db.session.commit()
db.session.close()
return {"success": True}

View File

@@ -7,7 +7,7 @@ from flask import redirect, render_template, request, session, url_for
from itsdangerous.exc import BadSignature, BadTimeSignature, SignatureExpired from itsdangerous.exc import BadSignature, BadTimeSignature, SignatureExpired
from CTFd.cache import clear_team_session, clear_user_session from CTFd.cache import clear_team_session, clear_user_session
from CTFd.models import Teams, Users, db from CTFd.models import Teams, UserFieldEntries, UserFields, Users, db
from CTFd.utils import config, email, get_app_config, get_config from CTFd.utils import config, email, get_app_config, get_config
from CTFd.utils import user as current_user from CTFd.utils import user as current_user
from CTFd.utils import validators from CTFd.utils import validators
@@ -206,6 +206,31 @@ def register():
valid_email = validators.validate_email(email_address) valid_email = validators.validate_email(email_address)
team_name_email_check = validators.validate_email(name) team_name_email_check = validators.validate_email(name)
# Process additional user fields
fields = {}
for field in UserFields.query.all():
fields[field.id] = field
entries = {}
for field_id, field in fields.items():
value = request.form.get(f"fields[{field_id}]", "").strip()
if field.required is True and (value is None or value == ""):
errors.append("Please provide all required fields")
break
# Handle special casing of existing profile fields
if field.name.lower() == "affiliation":
affiliation = value
break
elif field.name.lower() == "website":
website = value
break
if field.field_type == "boolean":
entries[field_id] = bool(value)
else:
entries[field_id] = value
if country: if country:
try: try:
validators.validate_country_code(country) validators.validate_country_code(country)
@@ -275,6 +300,13 @@ def register():
db.session.commit() db.session.commit()
db.session.flush() db.session.flush()
for field_id, value in entries.items():
entry = UserFieldEntries(
field_id=field_id, value=value, user_id=user.id
)
db.session.add(entry)
db.session.commit()
login_user(user) login_user(user)
if config.can_send_mail() and get_config( if config.can_send_mail() and get_config(

View File

@@ -4,13 +4,25 @@ from wtforms.validators import InputRequired
from CTFd.forms import BaseForm from CTFd.forms import BaseForm
from CTFd.forms.fields import SubmitField from CTFd.forms.fields import SubmitField
from CTFd.forms.users import attach_custom_user_fields, build_custom_user_fields
class RegistrationForm(BaseForm): def RegistrationForm(*args, **kwargs):
name = StringField("User Name", validators=[InputRequired()]) class _RegistrationForm(BaseForm):
email = EmailField("Email", validators=[InputRequired()]) name = StringField("User Name", validators=[InputRequired()])
password = PasswordField("Password", validators=[InputRequired()]) email = EmailField("Email", validators=[InputRequired()])
submit = SubmitField("Submit") password = PasswordField("Password", validators=[InputRequired()])
submit = SubmitField("Submit")
@property
def extra(self):
return build_custom_user_fields(
self, include_entries=False, blacklisted_items=()
)
attach_custom_user_fields(_RegistrationForm)
return _RegistrationForm(*args, **kwargs)
class LoginForm(BaseForm): class LoginForm(BaseForm):

View File

@@ -1,20 +1,36 @@
from flask import session
from wtforms import PasswordField, SelectField, StringField from wtforms import PasswordField, SelectField, StringField
from wtforms.fields.html5 import DateField, URLField from wtforms.fields.html5 import DateField, URLField
from CTFd.forms import BaseForm from CTFd.forms import BaseForm
from CTFd.forms.fields import SubmitField from CTFd.forms.fields import SubmitField
from CTFd.forms.users import attach_custom_user_fields, build_custom_user_fields
from CTFd.utils.countries import SELECT_COUNTRIES_LIST from CTFd.utils.countries import SELECT_COUNTRIES_LIST
class SettingsForm(BaseForm): def SettingsForm(*args, **kwargs):
name = StringField("User Name") class _SettingsForm(BaseForm):
email = StringField("Email") name = StringField("User Name")
password = PasswordField("Password") email = StringField("Email")
confirm = PasswordField("Current Password") password = PasswordField("Password")
affiliation = StringField("Affiliation") confirm = PasswordField("Current Password")
website = URLField("Website") affiliation = StringField("Affiliation")
country = SelectField("Country", choices=SELECT_COUNTRIES_LIST) website = URLField("Website")
submit = SubmitField("Submit") country = SelectField("Country", choices=SELECT_COUNTRIES_LIST)
submit = SubmitField("Submit")
@property
def extra(self):
return build_custom_user_fields(
self,
include_entries=True,
fields_kwargs={"editable": True},
field_entries_kwargs={"user_id": session["id"]},
)
attach_custom_user_fields(_SettingsForm, editable=True)
return _SettingsForm(*args, **kwargs)
class TokensForm(BaseForm): class TokensForm(BaseForm):

View File

@@ -4,9 +4,83 @@ from wtforms.validators import InputRequired
from CTFd.forms import BaseForm from CTFd.forms import BaseForm
from CTFd.forms.fields import SubmitField from CTFd.forms.fields import SubmitField
from CTFd.models import UserFieldEntries, UserFields
from CTFd.utils.countries import SELECT_COUNTRIES_LIST from CTFd.utils.countries import SELECT_COUNTRIES_LIST
def build_custom_user_fields(
form_cls,
include_entries=False,
fields_kwargs=None,
field_entries_kwargs=None,
blacklisted_items=("affiliation", "website"),
):
"""
Function used to reinject values back into forms for accessing by themes
"""
if fields_kwargs is None:
fields_kwargs = {}
if field_entries_kwargs is None:
field_entries_kwargs = {}
fields = []
new_fields = UserFields.query.filter_by(**fields_kwargs).all()
user_fields = {}
# Only include preexisting values if asked
if include_entries is True:
for f in UserFieldEntries.query.filter_by(**field_entries_kwargs).all():
user_fields[f.field_id] = f.value
for field in new_fields:
if field.name.lower() in blacklisted_items:
continue
form_field = getattr(form_cls, f"fields[{field.id}]")
# Add the field_type to the field so we know how to render it
form_field.field_type = field.field_type
# Only include preexisting values if asked
if include_entries is True:
initial = user_fields.get(field.id, "")
form_field.data = initial
if form_field.render_kw:
form_field.render_kw["data-initial"] = initial
else:
form_field.render_kw = {"data-initial": initial}
fields.append(form_field)
return fields
def attach_custom_user_fields(form_cls, **kwargs):
"""
Function used to attach form fields to wtforms.
Not really a great solution but is approved by wtforms.
https://wtforms.readthedocs.io/en/2.3.x/specific_problems/#dynamic-form-composition
"""
new_fields = UserFields.query.filter_by(**kwargs).all()
for field in new_fields:
validators = []
if field.required:
validators.append(InputRequired())
if field.field_type == "text":
input_field = StringField(
field.name, description=field.description, validators=validators
)
elif field.field_type == "boolean":
input_field = BooleanField(
field.name, description=field.description, validators=validators
)
setattr(
form_cls, f"fields[{field.id}]", input_field,
)
class UserSearchForm(BaseForm): class UserSearchForm(BaseForm):
field = SelectField( field = SelectField(
"Search Field", "Search Field",
@@ -40,7 +114,7 @@ class PublicUserSearchForm(BaseForm):
submit = SubmitField("Search") submit = SubmitField("Search")
class UserEditForm(BaseForm): class UserBaseForm(BaseForm):
name = StringField("User Name", validators=[InputRequired()]) name = StringField("User Name", validators=[InputRequired()])
email = EmailField("Email", validators=[InputRequired()]) email = EmailField("Email", validators=[InputRequired()])
password = PasswordField("Password") password = PasswordField("Password")
@@ -54,5 +128,41 @@ class UserEditForm(BaseForm):
submit = SubmitField("Submit") submit = SubmitField("Submit")
class UserCreateForm(UserEditForm): def UserEditForm(*args, **kwargs):
notify = BooleanField("Email account credentials to user", default=True) class _UserEditForm(UserBaseForm):
pass
@property
def extra(self):
return build_custom_user_fields(
self,
include_entries=True,
fields_kwargs=None,
field_entries_kwargs={"user_id": self.obj.id},
)
def __init__(self, *args, **kwargs):
"""
Custom init to persist the obj parameter to the rest of the form
"""
super().__init__(*args, **kwargs)
obj = kwargs.get("obj")
if obj:
self.obj = obj
attach_custom_user_fields(_UserEditForm)
return _UserEditForm(*args, **kwargs)
def UserCreateForm(*args, **kwargs):
class _UserCreateForm(UserBaseForm):
notify = BooleanField("Email account credentials to user", default=True)
@property
def extra(self):
return build_custom_user_fields(self, include_entries=False)
attach_custom_user_fields(_UserCreateForm)
return _UserCreateForm(*args, **kwargs)

View File

@@ -276,6 +276,10 @@ class Users(db.Model):
# Relationship for Teams # Relationship for Teams
team_id = db.Column(db.Integer, db.ForeignKey("teams.id")) team_id = db.Column(db.Integer, db.ForeignKey("teams.id"))
field_entries = db.relationship(
"UserFieldEntries", foreign_keys="UserFieldEntries.user_id", lazy="joined"
)
created = db.Column(db.DateTime, default=datetime.datetime.utcnow) created = db.Column(db.DateTime, default=datetime.datetime.utcnow)
__mapper_args__ = {"polymorphic_identity": "user", "polymorphic_on": type} __mapper_args__ = {"polymorphic_identity": "user", "polymorphic_on": type}
@@ -309,6 +313,10 @@ class Users(db.Model):
elif user_mode == "users": elif user_mode == "users":
return self return self
@property
def fields(self):
return self.get_fields(admin=False)
@property @property
def solves(self): def solves(self):
return self.get_solves(admin=False) return self.get_solves(admin=False)
@@ -334,6 +342,12 @@ class Users(db.Model):
else: else:
return None return None
def get_fields(self, admin=False):
if admin:
return self.field_entries
return [entry for entry in self.field_entries if entry.field.public]
def get_solves(self, admin=False): def get_solves(self, admin=False):
from CTFd.utils import get_config from CTFd.utils import get_config
@@ -781,3 +795,59 @@ class TeamComments(Comments):
class PageComments(Comments): class PageComments(Comments):
__mapper_args__ = {"polymorphic_identity": "page"} __mapper_args__ = {"polymorphic_identity": "page"}
page_id = db.Column(db.Integer, db.ForeignKey("pages.id", ondelete="CASCADE")) page_id = db.Column(db.Integer, db.ForeignKey("pages.id", ondelete="CASCADE"))
class Fields(db.Model):
__tablename__ = "fields"
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.Text)
type = db.Column(db.String(80), default="standard")
field_type = db.Column(db.String(80))
description = db.Column(db.Text)
required = db.Column(db.Boolean, default=False)
public = db.Column(db.Boolean, default=False)
editable = db.Column(db.Boolean, default=False)
__mapper_args__ = {"polymorphic_identity": "standard", "polymorphic_on": type}
class UserFields(Fields):
__mapper_args__ = {"polymorphic_identity": "user"}
class TeamFields(Fields):
__mapper_args__ = {"polymorphic_identity": "team"}
class FieldEntries(db.Model):
__tablename__ = "field_entries"
id = db.Column(db.Integer, primary_key=True)
type = db.Column(db.String(80), default="standard")
value = db.Column(db.JSON)
field_id = db.Column(db.Integer, db.ForeignKey("fields.id", ondelete="CASCADE"))
field = db.relationship(
"Fields", foreign_keys="FieldEntries.field_id", lazy="joined"
)
__mapper_args__ = {"polymorphic_identity": "standard", "polymorphic_on": type}
@hybrid_property
def name(self):
return self.field.name
@hybrid_property
def description(self):
return self.field.description
class UserFieldEntries(FieldEntries):
__mapper_args__ = {"polymorphic_identity": "user"}
user_id = db.Column(db.Integer, db.ForeignKey("users.id", ondelete="CASCADE"))
user = db.relationship("Users", foreign_keys="UserFieldEntries.user_id")
class TeamFieldEntries(FieldEntries):
__mapper_args__ = {"polymorphic_identity": "team"}
team_id = db.Column(db.Integer, db.ForeignKey("teams.id", ondelete="CASCADE"))
team = db.relationship("Teams", foreign_keys="TeamFieldEntries.team_id")

24
CTFd/schemas/fields.py Normal file
View File

@@ -0,0 +1,24 @@
from marshmallow import fields
from CTFd.models import Fields, UserFieldEntries, db, ma
class FieldSchema(ma.ModelSchema):
class Meta:
model = Fields
include_fk = True
dump_only = ("id",)
class UserFieldEntriesSchema(ma.ModelSchema):
class Meta:
model = UserFieldEntries
sqla_session = db.session
include_fk = True
load_only = ("id",)
exclude = ("field", "user", "user_id")
dump_only = ("user_id", "name", "description", "type")
name = fields.Nested(FieldSchema, only=("name"), attribute="field")
description = fields.Nested(FieldSchema, only=("description"), attribute="field")
type = fields.Nested(FieldSchema, only=("field_type"), attribute="field")

View File

@@ -1,7 +1,10 @@
from marshmallow import ValidationError, pre_load, validate from marshmallow import ValidationError, post_dump, pre_load, validate
from marshmallow.fields import Nested
from marshmallow_sqlalchemy import field_for from marshmallow_sqlalchemy import field_for
from sqlalchemy.orm import load_only
from CTFd.models import Users, ma from CTFd.models import UserFieldEntries, UserFields, Users, ma
from CTFd.schemas.fields import UserFieldEntriesSchema
from CTFd.utils import get_config, string_types from CTFd.utils import get_config, string_types
from CTFd.utils.crypto import verify_password from CTFd.utils.crypto import verify_password
from CTFd.utils.email import check_email_is_whitelisted from CTFd.utils.email import check_email_is_whitelisted
@@ -49,6 +52,9 @@ class UserSchema(ma.ModelSchema):
) )
country = field_for(Users, "country", validate=[validate_country_code]) country = field_for(Users, "country", validate=[validate_country_code])
password = field_for(Users, "password") password = field_for(Users, "password")
fields = Nested(
UserFieldEntriesSchema, partial=True, many=True, attribute="field_entries"
)
@pre_load @pre_load
def validate_name(self, data): def validate_name(self, data):
@@ -180,6 +186,116 @@ class UserSchema(ma.ModelSchema):
data.pop("password", None) data.pop("password", None)
data.pop("confirm", None) data.pop("confirm", None)
@pre_load
def validate_fields(self, data):
"""
This validator is used to only allow users to update the field entry for their user.
It's not possible to exclude it because without the PK Marshmallow cannot load the right instance
"""
fields = data.get("fields")
if fields is None:
return
current_user = get_current_user()
if is_admin():
user_id = data.get("id")
if user_id:
target_user = Users.query.filter_by(id=data["id"]).first()
else:
target_user = current_user
provided_ids = []
for f in fields:
f.pop("id", None)
field_id = f.get("field_id")
# # Check that we have an existing field for this. May be unnecessary b/c the foriegn key should enforce
field = UserFields.query.filter_by(id=field_id).first_or_404()
# Get the existing field entry if one exists
entry = UserFieldEntries.query.filter_by(
field_id=field.id, user_id=target_user.id
).first()
if entry:
f["id"] = entry.id
provided_ids.append(entry.id)
# Extremely dirty hack to prevent deleting previously provided data.
# This needs a better soln.
entries = (
UserFieldEntries.query.options(load_only("id"))
.filter_by(user_id=target_user.id)
.all()
)
for entry in entries:
if entry.id not in provided_ids:
fields.append({"id": entry.id})
else:
provided_ids = []
for f in fields:
# Remove any existing set
f.pop("id", None)
field_id = f.get("field_id")
# # Check that we have an existing field for this. May be unnecessary b/c the foriegn key should enforce
field = UserFields.query.filter_by(id=field_id).first_or_404()
if field.editable is False:
raise ValidationError(
f"Field {field.name} cannot be editted", field_names=["fields"]
)
# Get the existing field entry if one exists
entry = UserFieldEntries.query.filter_by(
field_id=field.id, user_id=current_user.id
).first()
if entry:
f["id"] = entry.id
provided_ids.append(entry.id)
# Extremely dirty hack to prevent deleting previously provided data.
# This needs a better soln.
entries = (
UserFieldEntries.query.options(load_only("id"))
.filter_by(user_id=current_user.id)
.all()
)
for entry in entries:
if entry.id not in provided_ids:
fields.append({"id": entry.id})
@post_dump
def process_fields(self, data):
"""
Handle permissions levels for fields.
This is post_dump to manipulate JSON instead of the raw db object
Admins can see all fields.
Users (self) can see their edittable and public fields
Public (user) can only see public fields
"""
# Gather all possible fields
removed_field_ids = []
fields = UserFields.query.all()
# Select fields for removal based on current view and properties of the field
for field in fields:
if self.view == "user":
if field.public is False:
removed_field_ids.append(field.id)
elif self.view == "self":
if field.editable is False and field.public is False:
removed_field_ids.append(field.id)
# Rebuild fuilds
fields = data.get("fields")
if fields:
data["fields"] = [
field for field in fields if field["field_id"] not in removed_field_ids
]
views = { views = {
"user": [ "user": [
"website", "website",
@@ -189,6 +305,7 @@ class UserSchema(ma.ModelSchema):
"bracket", "bracket",
"id", "id",
"oauth_id", "oauth_id",
"fields",
], ],
"self": [ "self": [
"website", "website",
@@ -200,6 +317,7 @@ class UserSchema(ma.ModelSchema):
"id", "id",
"oauth_id", "oauth_id",
"password", "password",
"fields",
], ],
"admin": [ "admin": [
"website", "website",
@@ -217,6 +335,7 @@ class UserSchema(ma.ModelSchema):
"password", "password",
"type", "type",
"verified", "verified",
"fields",
], ],
} }
@@ -226,5 +345,6 @@ class UserSchema(ma.ModelSchema):
kwargs["only"] = self.views[view] kwargs["only"] = self.views[view]
elif isinstance(view, list): elif isinstance(view, list):
kwargs["only"] = view kwargs["only"] = view
self.view = view
super(UserSchema, self).__init__(*args, **kwargs) super(UserSchema, self).__init__(*args, **kwargs)

View File

@@ -0,0 +1,200 @@
<template>
<div class="border-bottom">
<div>
<button
type="button"
class="close float-right"
aria-label="Close"
@click="deleteField()"
>
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="row">
<div class="col-md-3">
<div class="form-group">
<label>Field Type</label>
<select
class="form-control custom-select"
v-model.lazy="field.field_type"
>
<option value="text">Text Field</option>
<option value="checkbox">Checkbox</option>
</select>
<small class="form-text text-muted"
>Type of field shown to the user</small
>
</div>
</div>
<div class="col-md-9">
<div class="form-group">
<label>Field Name</label>
<input type="text" class="form-control" v-model.lazy="field.name" />
<small class="form-text text-muted">Field name</small>
</div>
</div>
<div class="col-md-12">
<div class="form-group">
<label>Field Description</label>
<input
type="text"
class="form-control"
v-model.lazy="field.description"
/>
<small id="emailHelp" class="form-text text-muted"
>Field Description</small
>
</div>
</div>
<div class="col-md-12">
<div class="form-check">
<label class="form-check-label">
<input
class="form-check-input"
type="checkbox"
v-model.lazy="field.editable"
/>
Editable by user in profile
</label>
</div>
<div class="form-check">
<label class="form-check-label">
<input
class="form-check-input"
type="checkbox"
v-model.lazy="field.required"
/>
Required on registration
</label>
</div>
<div class="form-check">
<label class="form-check-label">
<input
class="form-check-input"
type="checkbox"
v-model.lazy="field.public"
/>
Shown on public profile
</label>
</div>
</div>
</div>
<div class="row pb-3">
<div class="col-md-12">
<div class="d-block">
<button
class="btn btn-sm btn-success btn-outlined float-right"
type="button"
@click="saveField()"
>
Save
</button>
</div>
</div>
</div>
</div>
</template>
<script>
import CTFd from "core/CTFd";
import { ezToast } from "core/ezq";
export default {
props: {
index: Number,
initialField: Object
},
data: function() {
return {
field: this.initialField
};
},
methods: {
persistedField: function() {
// We're using Math.random() for unique IDs so new items have IDs < 1
// Real items will have an ID > 1
return this.field.id >= 1;
},
saveField: function() {
let body = this.field;
if (this.persistedField()) {
CTFd.fetch(`/api/v1/configs/fields/${this.field.id}`, {
method: "PATCH",
credentials: "same-origin",
headers: {
Accept: "application/json",
"Content-Type": "application/json"
},
body: JSON.stringify(body)
})
.then(response => {
return response.json();
})
.then(response => {
if (response.success === true) {
this.field = response.data;
ezToast({
title: "Success",
body: "Field has been updated!",
delay: 1000
});
}
});
} else {
CTFd.fetch(`/api/v1/configs/fields`, {
method: "POST",
credentials: "same-origin",
headers: {
Accept: "application/json",
"Content-Type": "application/json"
},
body: JSON.stringify(body)
})
.then(response => {
return response.json();
})
.then(response => {
if (response.success === true) {
this.field = response.data;
ezToast({
title: "Success",
body: "Field has been created!",
delay: 1000
});
}
});
}
},
deleteField: function() {
if (confirm("Are you sure you'd like to delete this field?")) {
if (this.persistedField()) {
CTFd.fetch(`/api/v1/configs/fields/${this.field.id}`, {
method: "DELETE",
credentials: "same-origin",
headers: {
Accept: "application/json",
"Content-Type": "application/json"
}
})
.then(response => {
return response.json();
})
.then(response => {
if (response.success === true) {
this.$emit("remove-field", this.index);
}
});
} else {
this.$emit("remove-field", this.index);
}
}
}
}
};
</script>
<style scoped></style>

View File

@@ -0,0 +1,80 @@
<template>
<div>
<!-- You can't use index as :key here b/c Vue is crazy -->
<!-- https://rimdev.io/the-v-for-key/ -->
<div class="mb-5" v-for="(field, index) in fields" :key="field.id">
<Field
:index="index"
:initialField.sync="fields[index]"
@remove-field="removeField"
/>
</div>
<div class="row">
<div class="col text-center">
<button
class="btn btn-sm btn-success btn-outlined m-auto"
type="button"
@click="addField()"
>
Add New Field
</button>
</div>
</div>
</div>
</template>
<script>
import CTFd from "core/CTFd";
import Field from "./Field.vue";
export default {
name: "FieldList",
components: {
Field
},
props: {},
data: function() {
return {
fields: []
};
},
methods: {
loadFields: function() {
CTFd.fetch("/api/v1/configs/fields?type=user", {
method: "GET",
credentials: "same-origin",
headers: {
Accept: "application/json",
"Content-Type": "application/json"
}
})
.then(response => {
return response.json();
})
.then(response => {
this.fields = response.data;
});
},
addField: function() {
this.fields.push({
id: Math.random(),
type: "user",
field_type: "text",
name: "",
description: "",
editable: false,
required: false,
public: false
});
},
removeField: function(index) {
this.fields.splice(index, 1);
console.log(this.fields);
}
},
created() {
this.loadFields();
}
};
</script>

View File

@@ -9,6 +9,8 @@ import $ from "jquery";
import { ezQuery, ezProgressBar, ezAlert } from "core/ezq"; import { ezQuery, ezProgressBar, ezAlert } from "core/ezq";
import CodeMirror from "codemirror"; import CodeMirror from "codemirror";
import "codemirror/mode/htmlmixed/htmlmixed.js"; import "codemirror/mode/htmlmixed/htmlmixed.js";
import Vue from "vue/dist/vue.esm.browser";
import FieldList from "../components/configs/fields/FieldList.vue";
function loadTimestamp(place, timestamp) { function loadTimestamp(place, timestamp) {
if (typeof timestamp == "string") { if (typeof timestamp == "string") {
@@ -360,4 +362,10 @@ $(() => {
$("#mail_username_password").toggle(this.checked); $("#mail_username_password").toggle(this.checked);
}) })
.change(); .change();
// Insert CommentBox element
const fieldList = Vue.extend(FieldList);
let vueContainer = document.createElement("div");
document.querySelector("#user-field-list").appendChild(vueContainer);
new fieldList({}).$mount(vueContainer);
}); });

View File

@@ -11,6 +11,19 @@ function createUser(event) {
event.preventDefault(); event.preventDefault();
const params = $("#user-info-create-form").serializeJSON(true); const params = $("#user-info-create-form").serializeJSON(true);
params.fields = [];
for (const property in params) {
if (property.match(/fields\[\d+\]/)) {
let field = {};
let id = parseInt(property.slice(7, -1));
field["field_id"] = id;
field["value"] = params[property];
params.fields.push(field);
delete params[property];
}
}
// Move the notify value into a GET param // Move the notify value into a GET param
let url = "/api/v1/users"; let url = "/api/v1/users";
let notify = params.notify; let notify = params.notify;
@@ -57,6 +70,19 @@ function updateUser(event) {
event.preventDefault(); event.preventDefault();
const params = $("#user-info-edit-form").serializeJSON(true); const params = $("#user-info-edit-form").serializeJSON(true);
params.fields = [];
for (const property in params) {
if (property.match(/fields\[\d+\]/)) {
let field = {};
let id = parseInt(property.slice(7, -1));
field["field_id"] = id;
field["value"] = params[property];
params.fields.push(field);
delete params[property];
}
}
CTFd.fetch("/api/v1/users/" + window.USER_ID, { CTFd.fetch("/api/v1/users/" + window.USER_ID, {
method: "PATCH", method: "PATCH",
credentials: "same-origin", credentials: "same-origin",

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -23,6 +23,9 @@
<li class="nav-item"> <li class="nav-item">
<a class="nav-link rounded-0" href="#accounts" role="tab" data-toggle="tab">Accounts</a> <a class="nav-link rounded-0" href="#accounts" role="tab" data-toggle="tab">Accounts</a>
</li> </li>
<li class="nav-item">
<a class="nav-link rounded-0" href="#fields" role="tab" data-toggle="tab">Custom Fields</a>
</li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link rounded-0" href="#mlc" role="tab" data-toggle="tab">MajorLeagueCyber</a> <a class="nav-link rounded-0" href="#mlc" role="tab" data-toggle="tab">MajorLeagueCyber</a>
</li> </li>
@@ -61,6 +64,8 @@
{% include "admin/configs/accounts.html" %} {% include "admin/configs/accounts.html" %}
{% include "admin/configs/fields.html" %}
{% include "admin/configs/mlc.html" %} {% include "admin/configs/mlc.html" %}
{% include "admin/configs/settings.html" %} {% include "admin/configs/settings.html" %}

View File

@@ -0,0 +1,28 @@
<div role="tabpanel" class="tab-pane config-section" id="fields">
<form method="POST" autocomplete="off" class="w-100">
<h5>Custom Fields</h5>
<small class="form-text text-muted">
Add custom fields to get additional data from your participants
</small>
<ul class="nav nav-tabs mt-3" role="tablist">
<li class="nav-item active">
<a class="nav-link active" href="#user-fields" role="tab" data-toggle="tab">
Users
</a>
</li>
</ul>
<div class="tab-content">
<div role="tabpanel" class="tab-pane active" id="user-fields">
<div class="col-md-12 py-3">
<small>Custom user fields are shown during registration. Users can optionally edit these fields in their profile.</small>
</div>
<div id="user-field-list" class="pt-3">
</div>
</div>
</div>
</form>
</div>

View File

@@ -0,0 +1,46 @@
{% macro render_extra_fields(fields, show_labels=True, show_optionals=True, show_descriptions=True) -%}
{% for field in fields %}
<div class="form-group">
{% if field.field_type == "text" %}
{% if show_labels %}
{{ field.label }}
{% endif %}
{% if show_optionals %}
{% if field.flags.required is false %}
<small class="float-right text-muted">Optional</small>
{% endif %}
{% endif %}
{{ field(class="form-control") }}
{% if show_descriptions %}
{% if field.description %}
<small class="form-text text-muted">
{{ field.description }}
</small>
{% endif %}
{% endif %}
{% elif field.field_type == "boolean" %}
<div class="form-check">
{{ field(class="form-check-input") }}
{{ field.label(class="form-check-label") }}
{% if show_optionals %}
{% if field.flags.required is false %}
<sup class="text-muted">Optional</sup>
{% endif %}
{% endif %}
</div>
{% if show_descriptions %}
{% if field.description %}
<small class="form-text text-muted">
{{ field.description }}
</small>
{% endif %}
{% endif %}
{% endif %}
</div>
{% endfor %}
{%- endmacro %}

View File

@@ -1,4 +1,5 @@
{% with form = Forms.users.UserCreateForm() %} {% with form = Forms.users.UserCreateForm() %}
{% from "admin/macros/forms.html" import render_extra_fields %}
<form id="user-info-create-form" method="POST"> <form id="user-info-create-form" method="POST">
<div class="form-group"> <div class="form-group">
{{ form.name.label }} {{ form.name.label }}
@@ -14,16 +15,22 @@
</div> </div>
<div class="form-group"> <div class="form-group">
{{ form.website.label }} {{ form.website.label }}
<small class="float-right text-muted align-text-bottom">Optional</small>
{{ form.website(class="form-control") }} {{ form.website(class="form-control") }}
</div> </div>
<div class="form-group"> <div class="form-group">
{{ form.affiliation.label }} {{ form.affiliation.label }}
<small class="float-right text-muted align-text-bottom">Optional</small>
{{ form.affiliation(class="form-control") }} {{ form.affiliation(class="form-control") }}
</div> </div>
<div class="form-group"> <div class="form-group">
{{ form.country.label }} {{ form.country.label }}
<small class="float-right text-muted align-text-bottom">Optional</small>
{{ form.country(class="form-control custom-select") }} {{ form.country(class="form-control custom-select") }}
</div> </div>
{{ render_extra_fields(form.extra) }}
<div class="form-group"> <div class="form-group">
<div class="form-check form-check-inline"> <div class="form-check form-check-inline">
{{ form.type(class="form-control form-inline custom-select", id="type-select") }} {{ form.type(class="form-control form-inline custom-select", id="type-select") }}

View File

@@ -1,4 +1,5 @@
{% with form = Forms.users.UserEditForm(obj=user) %} {% with form = Forms.users.UserEditForm(obj=user) %}
{% from "admin/macros/forms.html" import render_extra_fields %}
<form id="user-info-edit-form"> <form id="user-info-edit-form">
<div class="form-group"> <div class="form-group">
{{ form.name.label }} {{ form.name.label }}
@@ -24,6 +25,9 @@
{{ form.country.label }} {{ form.country.label }}
{{ form.country(class="form-control custom-select") }} {{ form.country(class="form-control custom-select") }}
</div> </div>
{{ render_extra_fields(form.extra) }}
<div class="form-group"> <div class="form-group">
<div class="form-check form-check-inline"> <div class="form-check form-check-inline">
{{ form.type(class="form-control form-inline custom-select", id="type-select") }} {{ form.type(class="form-control form-inline custom-select", id="type-select") }}

View File

@@ -132,6 +132,12 @@
</h2> </h2>
{% endif %} {% endif %}
{% for field in user.get_fields(admin=true) %}
<h3 class="d-block">
{{ field.name }}: {{ field.value }}
</h3>
{% endfor %}
<h3 id="team-place" class="text-center"> <h3 id="team-place" class="text-center">
{% if place %} {% if place %}
{{ place }} {{ place }}

View File

@@ -24,6 +24,19 @@ function profileUpdate(event) {
const $form = $(this); const $form = $(this);
let params = $form.serializeJSON(true); let params = $form.serializeJSON(true);
params.fields = [];
for (const property in params) {
if (property.match(/fields\[\d+\]/)) {
let field = {};
let id = parseInt(property.slice(7, -1));
field["field_id"] = id;
field["value"] = params[property];
params.fields.push(field);
delete params[property];
}
}
CTFd.api.patch_user_private({}, params).then(response => { CTFd.api.patch_user_private({}, params).then(response => {
if (response.success) { if (response.success) {
$("#results").html(success_template); $("#results").html(success_template);

View File

@@ -26,7 +26,7 @@ $.fn.serializeJSON = function(omit_nulls) {
if (x.value !== null && x.value !== "") { if (x.value !== null && x.value !== "") {
params[x.name] = x.value; params[x.name] = x.value;
} else { } else {
let input = form.find(`:input[name=${x.name}]`); let input = form.find(`:input[name='${x.name}']`);
if (input.data("initial") !== input.val()) { if (input.data("initial") !== input.val()) {
params[x.name] = x.value; params[x.name] = x.value;
} }

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -162,7 +162,7 @@
/***/ (function(module, exports, __webpack_require__) { /***/ (function(module, exports, __webpack_require__) {
; ;
eval("\n\n__webpack_require__(/*! ./main */ \"./CTFd/themes/core/assets/js/pages/main.js\");\n\nvar _utils = __webpack_require__(/*! ../utils */ \"./CTFd/themes/core/assets/js/utils.js\");\n\nvar _jquery = _interopRequireDefault(__webpack_require__(/*! jquery */ \"./node_modules/jquery/dist/jquery.js\"));\n\nvar _CTFd = _interopRequireDefault(__webpack_require__(/*! ../CTFd */ \"./CTFd/themes/core/assets/js/CTFd.js\"));\n\nvar _ezq = __webpack_require__(/*! ../ezq */ \"./CTFd/themes/core/assets/js/ezq.js\");\n\nfunction _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }\n\nvar error_template = '<div class=\"alert alert-danger alert-dismissable\" role=\"alert\">\\n' + ' <span class=\"sr-only\">Error:</span>\\n' + \" {0}\\n\" + ' <button type=\"button\" class=\"close\" data-dismiss=\"alert\" aria-label=\"Close\"><span aria-hidden=\"true\">×</span></button>\\n' + \"</div>\";\nvar success_template = '<div class=\"alert alert-success alert-dismissable submit-row\" role=\"alert\">\\n' + \" <strong>Success!</strong>\\n\" + \" Your profile has been updated\\n\" + ' <button type=\"button\" class=\"close\" data-dismiss=\"alert\" aria-label=\"Close\"><span aria-hidden=\"true\">×</span></button>\\n' + \"</div>\";\n\nfunction profileUpdate(event) {\n event.preventDefault();\n (0, _jquery.default)(\"#results\").empty();\n var $form = (0, _jquery.default)(this);\n var params = $form.serializeJSON(true);\n\n _CTFd.default.api.patch_user_private({}, params).then(function (response) {\n if (response.success) {\n (0, _jquery.default)(\"#results\").html(success_template);\n } else if (\"errors\" in response) {\n Object.keys(response.errors).map(function (error) {\n var i = $form.find(\"input[name={0}]\".format(error));\n var input = (0, _jquery.default)(i);\n input.addClass(\"input-filled-invalid\");\n input.removeClass(\"input-filled-valid\");\n var error_msg = response.errors[error];\n (0, _jquery.default)(\"#results\").append(error_template.format(error_msg));\n });\n }\n });\n}\n\nfunction tokenGenerate(event) {\n event.preventDefault();\n var $form = (0, _jquery.default)(this);\n var params = $form.serializeJSON(true);\n\n _CTFd.default.fetch(\"/api/v1/tokens\", {\n method: \"POST\",\n body: JSON.stringify(params)\n }).then(function (response) {\n return response.json();\n }).then(function (response) {\n if (response.success) {\n var body = (0, _jquery.default)(\"\\n <p>Please copy your API Key, it won't be shown again!</p>\\n <div class=\\\"input-group mb-3\\\">\\n <input type=\\\"text\\\" id=\\\"user-token-result\\\" class=\\\"form-control\\\" value=\\\"\".concat(response.data.value, \"\\\" readonly>\\n <div class=\\\"input-group-append\\\">\\n <button class=\\\"btn btn-outline-secondary\\\" type=\\\"button\\\">\\n <i class=\\\"fas fa-clipboard\\\"></i>\\n </button>\\n </div>\\n </div>\\n \"));\n body.find(\"button\").click(function (event) {\n (0, _utils.copyToClipboard)(event, \"#user-token-result\");\n });\n (0, _ezq.ezAlert)({\n title: \"API Key Generated\",\n body: body,\n button: \"Got it!\",\n large: true\n });\n }\n });\n}\n\nfunction deleteToken(event) {\n event.preventDefault();\n var $elem = (0, _jquery.default)(this);\n var id = $elem.data(\"token-id\");\n (0, _ezq.ezQuery)({\n title: \"Delete Token\",\n body: \"Are you sure you want to delete this token?\",\n success: function success() {\n _CTFd.default.fetch(\"/api/v1/tokens/\" + id, {\n method: \"DELETE\"\n }).then(function (response) {\n return response.json();\n }).then(function (response) {\n if (response.success) {\n $elem.parent().parent().remove();\n }\n });\n }\n });\n}\n\n(0, _jquery.default)(function () {\n (0, _jquery.default)(\"#user-profile-form\").submit(profileUpdate);\n (0, _jquery.default)(\"#user-token-form\").submit(tokenGenerate);\n (0, _jquery.default)(\".delete-token\").click(deleteToken);\n (0, _jquery.default)(\".nav-pills a\").click(function (_event) {\n window.location.hash = this.hash;\n }); // Load location hash\n\n var hash = window.location.hash;\n\n if (hash) {\n hash = hash.replace(\"<>[]'\\\"\", \"\");\n (0, _jquery.default)('.nav-pills a[href=\"' + hash + '\"]').tab(\"show\");\n }\n});\n\n//# sourceURL=webpack:///./CTFd/themes/core/assets/js/pages/settings.js?"); eval("\n\n__webpack_require__(/*! ./main */ \"./CTFd/themes/core/assets/js/pages/main.js\");\n\nvar _utils = __webpack_require__(/*! ../utils */ \"./CTFd/themes/core/assets/js/utils.js\");\n\nvar _jquery = _interopRequireDefault(__webpack_require__(/*! jquery */ \"./node_modules/jquery/dist/jquery.js\"));\n\nvar _CTFd = _interopRequireDefault(__webpack_require__(/*! ../CTFd */ \"./CTFd/themes/core/assets/js/CTFd.js\"));\n\nvar _ezq = __webpack_require__(/*! ../ezq */ \"./CTFd/themes/core/assets/js/ezq.js\");\n\nfunction _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }\n\nvar error_template = '<div class=\"alert alert-danger alert-dismissable\" role=\"alert\">\\n' + ' <span class=\"sr-only\">Error:</span>\\n' + \" {0}\\n\" + ' <button type=\"button\" class=\"close\" data-dismiss=\"alert\" aria-label=\"Close\"><span aria-hidden=\"true\">×</span></button>\\n' + \"</div>\";\nvar success_template = '<div class=\"alert alert-success alert-dismissable submit-row\" role=\"alert\">\\n' + \" <strong>Success!</strong>\\n\" + \" Your profile has been updated\\n\" + ' <button type=\"button\" class=\"close\" data-dismiss=\"alert\" aria-label=\"Close\"><span aria-hidden=\"true\">×</span></button>\\n' + \"</div>\";\n\nfunction profileUpdate(event) {\n event.preventDefault();\n (0, _jquery.default)(\"#results\").empty();\n var $form = (0, _jquery.default)(this);\n var params = $form.serializeJSON(true);\n params.fields = [];\n\n for (var property in params) {\n if (property.match(/fields\\[\\d+\\]/)) {\n var field = {};\n var id = parseInt(property.slice(7, -1));\n field[\"field_id\"] = id;\n field[\"value\"] = params[property];\n params.fields.push(field);\n delete params[property];\n }\n }\n\n _CTFd.default.api.patch_user_private({}, params).then(function (response) {\n if (response.success) {\n (0, _jquery.default)(\"#results\").html(success_template);\n } else if (\"errors\" in response) {\n Object.keys(response.errors).map(function (error) {\n var i = $form.find(\"input[name={0}]\".format(error));\n var input = (0, _jquery.default)(i);\n input.addClass(\"input-filled-invalid\");\n input.removeClass(\"input-filled-valid\");\n var error_msg = response.errors[error];\n (0, _jquery.default)(\"#results\").append(error_template.format(error_msg));\n });\n }\n });\n}\n\nfunction tokenGenerate(event) {\n event.preventDefault();\n var $form = (0, _jquery.default)(this);\n var params = $form.serializeJSON(true);\n\n _CTFd.default.fetch(\"/api/v1/tokens\", {\n method: \"POST\",\n body: JSON.stringify(params)\n }).then(function (response) {\n return response.json();\n }).then(function (response) {\n if (response.success) {\n var body = (0, _jquery.default)(\"\\n <p>Please copy your API Key, it won't be shown again!</p>\\n <div class=\\\"input-group mb-3\\\">\\n <input type=\\\"text\\\" id=\\\"user-token-result\\\" class=\\\"form-control\\\" value=\\\"\".concat(response.data.value, \"\\\" readonly>\\n <div class=\\\"input-group-append\\\">\\n <button class=\\\"btn btn-outline-secondary\\\" type=\\\"button\\\">\\n <i class=\\\"fas fa-clipboard\\\"></i>\\n </button>\\n </div>\\n </div>\\n \"));\n body.find(\"button\").click(function (event) {\n (0, _utils.copyToClipboard)(event, \"#user-token-result\");\n });\n (0, _ezq.ezAlert)({\n title: \"API Key Generated\",\n body: body,\n button: \"Got it!\",\n large: true\n });\n }\n });\n}\n\nfunction deleteToken(event) {\n event.preventDefault();\n var $elem = (0, _jquery.default)(this);\n var id = $elem.data(\"token-id\");\n (0, _ezq.ezQuery)({\n title: \"Delete Token\",\n body: \"Are you sure you want to delete this token?\",\n success: function success() {\n _CTFd.default.fetch(\"/api/v1/tokens/\" + id, {\n method: \"DELETE\"\n }).then(function (response) {\n return response.json();\n }).then(function (response) {\n if (response.success) {\n $elem.parent().parent().remove();\n }\n });\n }\n });\n}\n\n(0, _jquery.default)(function () {\n (0, _jquery.default)(\"#user-profile-form\").submit(profileUpdate);\n (0, _jquery.default)(\"#user-token-form\").submit(tokenGenerate);\n (0, _jquery.default)(\".delete-token\").click(deleteToken);\n (0, _jquery.default)(\".nav-pills a\").click(function (_event) {\n window.location.hash = this.hash;\n }); // Load location hash\n\n var hash = window.location.hash;\n\n if (hash) {\n hash = hash.replace(\"<>[]'\\\"\", \"\");\n (0, _jquery.default)('.nav-pills a[href=\"' + hash + '\"]').tab(\"show\");\n }\n});\n\n//# sourceURL=webpack:///./CTFd/themes/core/assets/js/pages/settings.js?");
/***/ }) /***/ })

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,46 @@
{% macro render_extra_fields(fields, show_labels=True, show_optionals=True, show_descriptions=True) -%}
{% for field in fields %}
<div class="form-group">
{% if field.field_type == "text" %}
{% if show_labels %}
<b>{{ field.label }}</b>
{% endif %}
{% if show_optionals %}
{% if field.flags.required is false %}
<small class="float-right text-muted">Optional</small>
{% endif %}
{% endif %}
{{ field(class="form-control") }}
{% if show_descriptions %}
{% if field.description %}
<small class="form-text text-muted">
{{ field.description }}
</small>
{% endif %}
{% endif %}
{% elif field.field_type == "boolean" %}
<div class="form-check">
{{ field(class="form-check-input") }}
{{ field.label(class="form-check-label") }}
{% if show_optionals %}
{% if field.flags.required is false %}
<sup class="text-muted">Optional</sup>
{% endif %}
{% endif %}
</div>
{% if show_descriptions %}
{% if field.description %}
<small class="form-text text-muted">
{{ field.description }}
</small>
{% endif %}
{% endif %}
{% endif %}
</div>
{% endfor %}
{%- endmacro %}

View File

@@ -23,20 +23,33 @@
{% endif %} {% endif %}
{% with form = Forms.auth.RegistrationForm() %} {% with form = Forms.auth.RegistrationForm() %}
{% from "macros/forms.html" import render_extra_fields %}
<form method="post" accept-charset="utf-8" autocomplete="off" role="form"> <form method="post" accept-charset="utf-8" autocomplete="off" role="form">
<div class="form-group"> <div class="form-group">
<b>{{ form.name.label }}</b> <b>{{ form.name.label }}</b>
{{ form.name(class="form-control", value=name) }} {{ form.name(class="form-control", value=name) }}
<small class="form-text text-muted">
Your username on the site
</small>
</div> </div>
<div class="form-group"> <div class="form-group">
<b>{{ form.email.label }}</b> <b>{{ form.email.label }}</b>
{{ form.email(class="form-control", value=email) }} {{ form.email(class="form-control", value=email) }}
<small class="form-text text-muted">
Never shown to the public
</small>
</div> </div>
<div class="form-group"> <div class="form-group">
<b>{{ form.password.label }}</b> <b>{{ form.password.label }}</b>
{{ form.password(class="form-control", value=password) }} {{ form.password(class="form-control", value=password) }}
<small class="form-text text-muted">
Password used to log into your account
</small>
</div> </div>
{{ form.nonce() }} {{ form.nonce() }}
{{ render_extra_fields(form.extra) }}
<div class="row pt-3"> <div class="row pt-3">
<div class="col-md-12"> <div class="col-md-12">
{{ form.submit(class="btn btn-md btn-primary btn-outlined float-right") }} {{ form.submit(class="btn btn-md btn-primary btn-outlined float-right") }}

View File

@@ -23,6 +23,7 @@
{% include "components/errors.html" %} {% include "components/errors.html" %}
{% with form = Forms.self.SettingsForm(country=country) %} {% with form = Forms.self.SettingsForm(country=country) %}
{% from "macros/forms.html" import render_extra_fields %}
<form id="user-profile-form" method="post" accept-charset="utf-8" autocomplete="off" role="form" <form id="user-profile-form" method="post" accept-charset="utf-8" autocomplete="off" role="form"
class="form-horizontal"> class="form-horizontal">
<div class="form-group"> <div class="form-group">
@@ -60,6 +61,10 @@
{{ form.country(class="form-control custom-select", value=country) }} {{ form.country(class="form-control custom-select", value=country) }}
</div> </div>
<hr>
{{ render_extra_fields(form.extra) }}
<div id="results" class="form-group"> <div id="results" class="form-group">
</div> </div>

View File

@@ -39,6 +39,12 @@
</h3> </h3>
{% endif %} {% endif %}
{% for field in user.fields %}
<h3 class="d-inline-block">
{{ field.name }}: {{ field.value }}
</h3>
{% endfor %}
<div> <div>
<h2 class="text-center"> <h2 class="text-center">
{% if account.place %} {% if account.place %}

View File

@@ -39,6 +39,12 @@
</h3> </h3>
{% endif %} {% endif %}
{% for field in user.fields %}
<h3 class="d-block">
{{ field.name }}: {{ field.value }}
</h3>
{% endfor %}
<div> <div>
<h2 class="text-center"> <h2 class="text-center">
{% if account.place %} {% if account.place %}

View File

@@ -0,0 +1,53 @@
"""Add Fields and FieldEntries tables
Revision ID: 75e8ab9a0014
Revises: 0366ba6575ca
Create Date: 2020-08-19 00:36:17.579497
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "75e8ab9a0014"
down_revision = "0366ba6575ca"
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"fields",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("name", sa.Text(), nullable=True),
sa.Column("type", sa.String(length=80), nullable=True),
sa.Column("field_type", sa.String(length=80), nullable=True),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("required", sa.Boolean(), nullable=True),
sa.Column("public", sa.Boolean(), nullable=True),
sa.Column("editable", sa.Boolean(), nullable=True),
sa.PrimaryKeyConstraint("id"),
)
op.create_table(
"field_entries",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("type", sa.String(length=80), nullable=True),
sa.Column("value", sa.JSON(), nullable=True),
sa.Column("field_id", sa.Integer(), nullable=True),
sa.Column("user_id", sa.Integer(), nullable=True),
sa.Column("team_id", sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(["field_id"], ["fields.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["team_id"], ["teams.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table("field_entries")
op.drop_table("fields")
# ### end Alembic commands ###

View File

@@ -0,0 +1,39 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
from tests.helpers import (
create_ctfd,
destroy_ctfd,
gen_field,
login_as_user,
register_user,
)
def test_admin_view_fields():
app = create_ctfd()
with app.app_context():
register_user(app)
gen_field(
app.db, name="CustomField1", required=True, public=True, editable=True
)
gen_field(
app.db, name="CustomField2", required=False, public=True, editable=True
)
gen_field(
app.db, name="CustomField3", required=False, public=False, editable=True
)
gen_field(
app.db, name="CustomField4", required=False, public=False, editable=False
)
with login_as_user(app, name="admin") as admin:
# Admins should see all user fields regardless of public or editable
r = admin.get("/admin/users/2")
resp = r.get_data(as_text=True)
assert "CustomField1" in resp
assert "CustomField2" in resp
assert "CustomField3" in resp
assert "CustomField4" in resp
destroy_ctfd(app)

253
tests/api/v1/test_fields.py Normal file
View File

@@ -0,0 +1,253 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
from CTFd.models import Fields, UserFieldEntries
from tests.helpers import (
create_ctfd,
destroy_ctfd,
gen_field,
login_as_user,
register_user,
)
def test_api_custom_fields():
app = create_ctfd()
with app.app_context():
register_user(app)
gen_field(app.db, name="CustomField1")
gen_field(app.db, name="CustomField2")
with login_as_user(app) as user:
r = user.get("/api/v1/configs/fields", json="")
assert r.status_code == 403
with login_as_user(app, name="admin") as admin:
r = admin.get("/api/v1/configs/fields", json="")
resp = r.get_json()
assert resp == {
"success": True,
"data": [
{
"public": True,
"required": True,
"type": "user",
"editable": True,
"id": 1,
"field_type": "text",
"description": "CustomFieldDescription",
"name": "CustomField1",
},
{
"public": True,
"required": True,
"type": "user",
"editable": True,
"id": 2,
"field_type": "text",
"description": "CustomFieldDescription",
"name": "CustomField2",
},
],
}
r = admin.post(
"/api/v1/configs/fields",
json={
"public": True,
"required": True,
"editable": True,
"id": 2,
"type": "user",
"field_type": "text",
"description": "CustomFieldDescription",
"name": "CustomField3",
},
)
assert r.status_code == 200
r = admin.get("/api/v1/configs/fields", json="")
resp = r.get_json()
assert resp == {
"success": True,
"data": [
{
"public": True,
"required": True,
"type": "user",
"editable": True,
"id": 1,
"field_type": "text",
"description": "CustomFieldDescription",
"name": "CustomField1",
},
{
"public": True,
"required": True,
"type": "user",
"editable": True,
"id": 2,
"field_type": "text",
"description": "CustomFieldDescription",
"name": "CustomField2",
},
{
"public": True,
"required": True,
"editable": True,
"id": 3,
"type": "user",
"field_type": "text",
"description": "CustomFieldDescription",
"name": "CustomField3",
},
],
}
r = admin.patch(
"/api/v1/configs/fields/3",
json={
"public": False,
"required": False,
"editable": False,
"id": 4,
"type": "user",
"field_type": "text",
"description": "CustomFieldDescription",
"name": "PatchedCustomField3",
},
)
assert r.status_code == 200
assert r.get_json()["data"] == {
"public": False,
"required": False,
"editable": False,
"id": 3,
"type": "user",
"field_type": "text",
"description": "CustomFieldDescription",
"name": "PatchedCustomField3",
}
r = admin.get("/api/v1/configs/fields/3", json="")
assert r.status_code == 200
assert r.get_json()["data"] == {
"public": False,
"required": False,
"editable": False,
"id": 3,
"type": "user",
"field_type": "text",
"description": "CustomFieldDescription",
"name": "PatchedCustomField3",
}
r = admin.delete("/api/v1/configs/fields/3", json="")
assert r.status_code == 200
r = admin.get("/api/v1/configs/fields/3", json="")
assert r.status_code == 404
destroy_ctfd(app)
def test_api_self_fields_permissions():
app = create_ctfd()
with app.app_context():
gen_field(app.db, name="CustomField1", public=False, editable=False)
gen_field(app.db, name="CustomField2", public=True, editable=True)
with app.test_client() as client:
client.get("/register")
with client.session_transaction() as sess:
data = {
"name": "user",
"email": "user@ctfd.io",
"password": "password",
"nonce": sess.get("nonce"),
"fields[1]": "CustomValue1",
"fields[2]": "CustomValue2",
}
r = client.post("/register", data=data)
with client.session_transaction() as sess:
assert sess["id"]
with login_as_user(app) as user, login_as_user(app, name="admin") as admin:
r = user.get("/api/v1/users/me")
resp = r.get_json()
assert resp["data"]["fields"] == [
{
"value": "CustomValue2",
"name": "CustomField2",
"description": "CustomFieldDescription",
"type": "text",
"field_id": 2,
}
]
r = admin.get("/api/v1/users/2")
resp = r.get_json()
assert len(resp["data"]["fields"]) == 2
field = Fields.query.filter_by(id=1).first()
field.public = True
app.db.session.commit()
r = user.get("/api/v1/users/me")
resp = r.get_json()
assert len(resp["data"]["fields"]) == 2
destroy_ctfd(app)
def test_partial_field_update():
app = create_ctfd()
with app.app_context():
register_user(app)
gen_field(app.db, name="CustomField1")
gen_field(app.db, name="CustomField2")
with login_as_user(app) as user:
r = user.patch(
"/api/v1/users/me",
json={
"fields": [
{"field_id": 1, "value": "CustomValue1"},
{"field_id": 2, "value": "CustomValue2"},
]
},
)
assert r.status_code == 200
assert UserFieldEntries.query.count() == 2
r = user.patch(
"/api/v1/users/me",
json={"fields": [{"field_id": 2, "value": "NewCustomValue2"}]},
)
assert r.status_code == 200
assert UserFieldEntries.query.count() == 2
assert (
UserFieldEntries.query.filter_by(field_id=1, user_id=2).first().value
== "CustomValue1"
)
assert (
UserFieldEntries.query.filter_by(field_id=2, user_id=2).first().value
== "NewCustomValue2"
)
with login_as_user(app, name="admin") as admin:
r = admin.patch(
"/api/v1/users/2",
json={"fields": [{"field_id": 2, "value": "AdminNewCustomValue2"}]},
)
assert r.status_code == 200
assert UserFieldEntries.query.count() == 2
assert (
UserFieldEntries.query.filter_by(field_id=1, user_id=2).first().value
== "CustomValue1"
)
assert (
UserFieldEntries.query.filter_by(field_id=2, user_id=2).first().value
== "AdminNewCustomValue2"
)
destroy_ctfd(app)

View File

@@ -22,6 +22,7 @@ from CTFd.models import (
Challenges, Challenges,
Comments, Comments,
Fails, Fails,
Fields,
Files, Files,
Flags, Flags,
Hints, Hints,
@@ -458,6 +459,30 @@ def gen_comment(db, content="comment", author_id=None, type="challenge", **kwarg
return comment return comment
def gen_field(
db,
name="CustomField",
type="user",
field_type="text",
description="CustomFieldDescription",
required=True,
public=True,
editable=True,
):
field = Fields(
name=name,
type=type,
field_type=field_type,
description=description,
required=required,
public=public,
editable=editable,
)
db.session.add(field)
db.session.commit()
return field
def simulate_user_activity(db, user): def simulate_user_activity(db, user):
gen_tracking(db, user_id=user.id) gen_tracking(db, user_id=user.id)
gen_award(db, user_id=user.id) gen_award(db, user_id=user.id)

204
tests/users/test_fields.py Normal file
View File

@@ -0,0 +1,204 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
from CTFd.models import UserFieldEntries
from tests.helpers import (
create_ctfd,
destroy_ctfd,
gen_field,
login_as_user,
register_user,
)
def test_new_fields_show_on_pages():
app = create_ctfd()
with app.app_context():
register_user(app)
gen_field(app.db)
with login_as_user(app) as client:
r = client.get("/register")
assert "CustomField" in r.get_data(as_text=True)
assert "CustomFieldDescription" in r.get_data(as_text=True)
r = client.get("/settings")
assert "CustomField" in r.get_data(as_text=True)
assert "CustomFieldDescription" in r.get_data(as_text=True)
r = client.patch(
"/api/v1/users/me",
json={"fields": [{"field_id": 1, "value": "CustomFieldEntry"}]},
)
resp = r.get_json()
assert resp["success"] is True
assert resp["data"]["fields"][0]["value"] == "CustomFieldEntry"
assert resp["data"]["fields"][0]["description"] == "CustomFieldDescription"
assert resp["data"]["fields"][0]["name"] == "CustomField"
assert resp["data"]["fields"][0]["field_id"] == 1
r = client.get("/user")
resp = r.get_data(as_text=True)
assert "CustomField" in resp
assert "CustomFieldEntry" in resp
r = client.get("/users/2")
resp = r.get_data(as_text=True)
assert "CustomField" in resp
assert "CustomFieldEntry" in resp
destroy_ctfd(app)
def test_fields_required_on_register():
app = create_ctfd()
with app.app_context():
gen_field(app.db)
with app.app_context():
with app.test_client() as client:
client.get("/register")
with client.session_transaction() as sess:
data = {
"name": "user",
"email": "user@ctfd.io",
"password": "password",
"nonce": sess.get("nonce"),
}
client.post("/register", data=data)
with client.session_transaction() as sess:
assert sess.get("id") is None
with client.session_transaction() as sess:
data = {
"name": "user",
"email": "user@ctfd.io",
"password": "password",
"fields[1]": "custom_field_value",
"nonce": sess.get("nonce"),
}
client.post("/register", data=data)
with client.session_transaction() as sess:
assert sess["id"]
destroy_ctfd(app)
def test_fields_properties():
app = create_ctfd()
with app.app_context():
register_user(app)
gen_field(
app.db, name="CustomField1", required=True, public=True, editable=True
)
gen_field(
app.db, name="CustomField2", required=False, public=True, editable=True
)
gen_field(
app.db, name="CustomField3", required=False, public=False, editable=True
)
gen_field(
app.db, name="CustomField4", required=False, public=False, editable=False
)
with login_as_user(app) as client:
r = client.get("/register")
resp = r.get_data(as_text=True)
assert "CustomField1" in resp
assert "CustomField2" in resp
assert "CustomField3" in resp
assert "CustomField4" in resp
r = client.get("/settings")
resp = r.get_data(as_text=True)
assert "CustomField1" in resp
assert "CustomField2" in resp
assert "CustomField3" in resp
assert "CustomField4" not in resp
r = client.patch(
"/api/v1/users/me",
json={
"fields": [
{"field_id": 1, "value": "CustomFieldEntry1"},
{"field_id": 2, "value": "CustomFieldEntry2"},
{"field_id": 3, "value": "CustomFieldEntry3"},
{"field_id": 4, "value": "CustomFieldEntry4"},
]
},
)
resp = r.get_json()
assert resp == {
"success": False,
"errors": {"fields": ["Field CustomField4 cannot be editted"]},
}
r = client.patch(
"/api/v1/users/me",
json={
"fields": [
{"field_id": 1, "value": "CustomFieldEntry1"},
{"field_id": 2, "value": "CustomFieldEntry2"},
{"field_id": 3, "value": "CustomFieldEntry3"},
]
},
)
assert r.status_code == 200
r = client.get("/user")
resp = r.get_data(as_text=True)
assert "CustomField1" in resp
assert "CustomField2" in resp
assert "CustomField3" not in resp
assert "CustomField4" not in resp
r = client.get("/users/2")
resp = r.get_data(as_text=True)
assert "CustomField1" in resp
assert "CustomField2" in resp
assert "CustomField3" not in resp
assert "CustomField4" not in resp
destroy_ctfd(app)
def test_boolean_checkbox_field():
app = create_ctfd()
with app.app_context():
gen_field(app.db, name="CustomField1", field_type="boolean", required=False)
with app.app_context():
with app.test_client() as client:
r = client.get("/register")
resp = r.get_data(as_text=True)
# We should have rendered a checkbox input
assert "checkbox" in resp
with client.session_transaction() as sess:
data = {
"name": "user",
"email": "user@ctfd.io",
"password": "password",
"nonce": sess.get("nonce"),
"fields[1]": "y",
}
client.post("/register", data=data)
with client.session_transaction() as sess:
assert sess["id"]
assert UserFieldEntries.query.count() == 1
assert UserFieldEntries.query.filter_by(id=1).first().value is True
with login_as_user(app) as client:
r = client.get("/settings")
resp = r.get_data(as_text=True)
assert "CustomField1" in resp
assert "checkbox" in resp
r = client.patch(
"/api/v1/users/me", json={"fields": [{"field_id": 1, "value": False}]},
)
assert r.status_code == 200
assert UserFieldEntries.query.count() == 1
assert UserFieldEntries.query.filter_by(id=1).first().value is False
destroy_ctfd(app)