online-judge/judge/forms.py
Tudor Brindus c126f07fe7 Revert "Completely remove Pagedown" and "Switch all Pagedown editors to Martor"
This reverts commits:
- 2035e606f3
- ce1196f74f

Since they do not interact well with dark mode.
2023-12-25 12:17:56 -05:00

319 lines
13 KiB
Python

import json
from operator import attrgetter, itemgetter
import pyotp
import webauthn
from django import forms
from django.conf import settings
from django.contrib.auth.forms import AuthenticationForm
from django.contrib.auth.models import User
from django.core.exceptions import ValidationError
from django.core.validators import RegexValidator
from django.db.models import Q
from django.forms import BooleanField, CharField, ChoiceField, Form, ModelForm, MultipleChoiceField
from django.urls import reverse_lazy
from django.utils.text import format_lazy
from django.utils.translation import gettext_lazy as _, ngettext_lazy
from django_ace import AceWidget
from judge.models import Contest, Language, Organization, Problem, ProblemPointsVote, Profile, Submission, \
WebAuthnCredential
from judge.utils.mail import validate_email_domain
from judge.utils.subscription import newsletter_id
from judge.widgets import HeavyPreviewPageDownWidget, Select2MultipleWidget, Select2Widget
TOTP_CODE_LENGTH = 6
two_factor_validators_by_length = {
TOTP_CODE_LENGTH: {
'regex_validator': RegexValidator(
f'^[0-9]{{{TOTP_CODE_LENGTH}}}$',
format_lazy(ngettext_lazy('Two-factor authentication tokens must be {count} decimal digit.',
'Two-factor authentication tokens must be {count} decimal digits.',
TOTP_CODE_LENGTH), count=TOTP_CODE_LENGTH),
),
'verify': lambda code, profile: not profile.check_totp_code(code),
'err': _('Invalid two-factor authentication token.'),
},
16: {
'regex_validator': RegexValidator('^[A-Z0-9]{16}$', _('Scratch codes must be 16 Base32 characters.')),
'verify': lambda code, profile: code not in json.loads(profile.scratch_codes),
'err': _('Invalid scratch code.'),
},
}
class ProfileForm(ModelForm):
if newsletter_id is not None:
newsletter = forms.BooleanField(label=_('Subscribe to contest updates'), initial=False, required=False)
test_site = forms.BooleanField(label=_('Enable experimental features'), initial=False, required=False)
class Meta:
model = Profile
fields = ['about', 'organizations', 'timezone', 'language', 'ace_theme', 'site_theme', 'user_script']
widgets = {
'timezone': Select2Widget(attrs={'style': 'width:200px'}),
'language': Select2Widget(attrs={'style': 'width:200px'}),
'ace_theme': Select2Widget(attrs={'style': 'width:200px'}),
'site_theme': Select2Widget(attrs={'style': 'width:200px'}),
}
has_math_config = bool(settings.MATHOID_URL)
if has_math_config:
fields.append('math_engine')
widgets['math_engine'] = Select2Widget(attrs={'style': 'width:200px'})
if HeavyPreviewPageDownWidget is not None:
widgets['about'] = HeavyPreviewPageDownWidget(
preview=reverse_lazy('profile_preview'),
attrs={'style': 'max-width:700px;min-width:700px;width:700px'},
)
def clean_about(self):
if 'about' in self.changed_data and not self.instance.has_any_solves:
raise ValidationError(_('You must solve at least one problem before you can update your profile.'))
return self.cleaned_data['about']
def clean(self):
organizations = self.cleaned_data.get('organizations') or []
max_orgs = settings.DMOJ_USER_MAX_ORGANIZATION_COUNT
if sum(org.is_open for org in organizations) > max_orgs:
raise ValidationError(ngettext_lazy('You may not be part of more than {count} public organization.',
'You may not be part of more than {count} public organizations.',
max_orgs).format(count=max_orgs))
return self.cleaned_data
def __init__(self, *args, **kwargs):
user = kwargs.pop('user', None)
super(ProfileForm, self).__init__(*args, **kwargs)
if not user.has_perm('judge.edit_all_organization'):
self.fields['organizations'].queryset = Organization.objects.filter(
Q(is_open=True) | Q(id__in=user.profile.organizations.all()),
)
if not self.fields['organizations'].queryset:
self.fields.pop('organizations')
self.fields['user_script'].widget = AceWidget(mode='javascript', theme=user.profile.resolved_ace_theme)
class EmailChangeForm(Form):
password = CharField(widget=forms.PasswordInput())
email = forms.EmailField()
def __init__(self, *args, user, **kwargs):
super().__init__(*args, **kwargs)
self.user = user
def clean_email(self):
if User.objects.filter(email=self.cleaned_data['email']).exists():
raise ValidationError(_('This email address is already taken.'))
validate_email_domain(self.cleaned_data['email'])
return self.cleaned_data['email']
def clean_password(self):
if not self.user.check_password(self.cleaned_data['password']):
raise ValidationError(_('Invalid password'))
return self.cleaned_data['password']
class DownloadDataForm(Form):
comment_download = BooleanField(required=False, label=_('Download comments?'))
submission_download = BooleanField(required=False, label=_('Download submissions?'))
submission_problem_glob = CharField(initial='*', label=_('Filter by problem code glob:'), max_length=100)
submission_results = MultipleChoiceField(
required=False,
widget=Select2MultipleWidget(
attrs={'style': 'width: 260px', 'data-placeholder': _('Leave empty to include all submissions')},
),
choices=sorted(map(itemgetter(0, 0), Submission.RESULT)),
label=_('Filter by result:'),
)
def clean(self):
can_download = ('comment_download', 'submission_download')
if not any(self.cleaned_data[v] for v in can_download):
raise ValidationError(_('Please select at least one thing to download.'))
return self.cleaned_data
def clean_submission_problem_glob(self):
if not self.cleaned_data['submission_download']:
return '*'
return self.cleaned_data['submission_problem_glob']
def clean_submission_result(self):
if not self.cleaned_data['submission_download']:
return ()
return self.cleaned_data['submission_result']
class ProblemSubmitForm(ModelForm):
source = CharField(max_length=65536, widget=AceWidget(theme='twilight', no_ace_media=True))
judge = ChoiceField(choices=(), widget=forms.HiddenInput(), required=False)
def __init__(self, *args, judge_choices=(), **kwargs):
super(ProblemSubmitForm, self).__init__(*args, **kwargs)
self.fields['language'].empty_label = None
self.fields['language'].label_from_instance = attrgetter('display_name')
self.fields['language'].queryset = Language.objects.filter(judges__online=True).distinct()
if judge_choices:
self.fields['judge'].widget = Select2Widget(
attrs={'style': 'width: 150px', 'data-placeholder': _('Any judge')},
)
self.fields['judge'].choices = judge_choices
class Meta:
model = Submission
fields = ['language']
class EditOrganizationForm(ModelForm):
class Meta:
model = Organization
fields = ['about', 'logo_override_image', 'admins']
widgets = {'admins': Select2MultipleWidget(attrs={'style': 'width: 200px'})}
if HeavyPreviewPageDownWidget is not None:
widgets['about'] = HeavyPreviewPageDownWidget(preview=reverse_lazy('organization_preview'))
class CustomAuthenticationForm(AuthenticationForm):
def __init__(self, *args, **kwargs):
super(CustomAuthenticationForm, self).__init__(*args, **kwargs)
self.fields['username'].widget.attrs.update({'placeholder': _('Username')})
self.fields['password'].widget.attrs.update({'placeholder': _('Password')})
self.has_google_auth = self._has_social_auth('GOOGLE_OAUTH2')
self.has_facebook_auth = self._has_social_auth('FACEBOOK')
self.has_github_auth = self._has_social_auth('GITHUB_SECURE')
def _has_social_auth(self, key):
return (getattr(settings, 'SOCIAL_AUTH_%s_KEY' % key, None) and
getattr(settings, 'SOCIAL_AUTH_%s_SECRET' % key, None))
class NoAutoCompleteCharField(forms.CharField):
def widget_attrs(self, widget):
attrs = super(NoAutoCompleteCharField, self).widget_attrs(widget)
attrs['autocomplete'] = 'off'
return attrs
class TOTPForm(Form):
TOLERANCE = settings.DMOJ_TOTP_TOLERANCE_HALF_MINUTES
totp_or_scratch_code = NoAutoCompleteCharField(required=False, widget=forms.TextInput(attrs={'autofocus': True}))
def __init__(self, *args, **kwargs):
self.profile = kwargs.pop('profile')
super().__init__(*args, **kwargs)
def clean(self):
totp_or_scratch_code = self.cleaned_data.get('totp_or_scratch_code')
try:
validator = two_factor_validators_by_length[len(totp_or_scratch_code)]
except KeyError:
raise ValidationError(_('Invalid code length.'))
validator['regex_validator'](totp_or_scratch_code)
if validator['verify'](totp_or_scratch_code, self.profile):
raise ValidationError(validator['err'])
class TOTPEnableForm(TOTPForm):
def __init__(self, *args, **kwargs):
self.totp_key = kwargs.pop('totp_key')
super().__init__(*args, **kwargs)
def clean(self):
totp_validate = two_factor_validators_by_length[TOTP_CODE_LENGTH]
code = self.cleaned_data.get('totp_or_scratch_code')
totp_validate['regex_validator'](code)
if not pyotp.TOTP(self.totp_key).verify(code, valid_window=settings.DMOJ_TOTP_TOLERANCE_HALF_MINUTES):
raise ValidationError(totp_validate['err'])
class TwoFactorLoginForm(TOTPForm):
webauthn_response = forms.CharField(widget=forms.HiddenInput(), required=False)
def __init__(self, *args, **kwargs):
self.webauthn_challenge = kwargs.pop('webauthn_challenge')
self.webauthn_origin = kwargs.pop('webauthn_origin')
super().__init__(*args, **kwargs)
def clean(self):
totp_or_scratch_code = self.cleaned_data.get('totp_or_scratch_code')
if self.profile.is_webauthn_enabled and self.cleaned_data.get('webauthn_response'):
if len(self.cleaned_data['webauthn_response']) > 65536:
raise ValidationError(_('Invalid WebAuthn response.'))
if not self.webauthn_challenge:
raise ValidationError(_('No WebAuthn challenge issued.'))
response = json.loads(self.cleaned_data['webauthn_response'])
try:
credential = self.profile.webauthn_credentials.get(cred_id=response.get('id', ''))
except WebAuthnCredential.DoesNotExist:
raise ValidationError(_('Invalid WebAuthn credential ID.'))
user = credential.webauthn_user
# Work around a useless check in the webauthn package.
user.credential_id = credential.cred_id
assertion = webauthn.WebAuthnAssertionResponse(
webauthn_user=user,
assertion_response=response.get('response'),
challenge=self.webauthn_challenge,
origin=self.webauthn_origin,
uv_required=False,
)
try:
sign_count = assertion.verify()
except Exception as e:
raise ValidationError(str(e))
credential.counter = sign_count
credential.save(update_fields=['counter'])
elif totp_or_scratch_code:
if self.profile.is_totp_enabled and self.profile.check_totp_code(totp_or_scratch_code):
return
elif self.profile.scratch_codes and totp_or_scratch_code in json.loads(self.profile.scratch_codes):
scratch_codes = json.loads(self.profile.scratch_codes)
scratch_codes.remove(totp_or_scratch_code)
self.profile.scratch_codes = json.dumps(scratch_codes)
self.profile.save(update_fields=['scratch_codes'])
return
elif self.profile.is_totp_enabled:
raise ValidationError(_('Invalid two-factor authentication token or scratch code.'))
else:
raise ValidationError(_('Invalid scratch code.'))
else:
raise ValidationError(_('Must specify either totp_token or webauthn_response.'))
class ProblemCloneForm(Form):
code = CharField(max_length=20, validators=[RegexValidator('^[a-z0-9]+$', _('Problem code must be ^[a-z0-9]+$'))])
def clean_code(self):
code = self.cleaned_data['code']
if Problem.objects.filter(code=code).exists():
raise ValidationError(_('Problem with code already exists.'))
return code
class ContestCloneForm(Form):
key = CharField(max_length=20, validators=[RegexValidator('^[a-z0-9]+$', _('Contest id must be ^[a-z0-9]+$'))])
def clean_key(self):
key = self.cleaned_data['key']
if Contest.objects.filter(key=key).exists():
raise ValidationError(_('Contest with key already exists.'))
return key
class ProblemPointsVoteForm(ModelForm):
note = CharField(max_length=8192, required=False)
class Meta:
model = ProblemPointsVote
fields = ['points', 'note']