diff --git a/README.md b/README.md index ddc7daf..1e8173b 100644 --- a/README.md +++ b/README.md @@ -22,4 +22,6 @@ That means you have the right to study, change, and distribute the software and Thanks to the Protospace Portal Committee. +Thanks to Emrah for lockout certification code, Pat for LDAP code, and Murray for the blank member form PDF. + Thanks to all the devs behind Python, Django, DRF, Node, React, Quill, and Bleach. diff --git a/apiserver/apiserver/api/management/commands/delete_old_static.py b/apiserver/apiserver/api/management/commands/delete_old_static.py new file mode 100644 index 0000000..c123654 --- /dev/null +++ b/apiserver/apiserver/api/management/commands/delete_old_static.py @@ -0,0 +1,50 @@ +from django.core.management.base import BaseCommand, CommandError +from django.utils.timezone import now + +from apiserver import settings +from apiserver.api import models, utils, utils_stats + +import time +import os + +if settings.DEBUG: + STATIC_FOLDER = './data/static/' +else: + STATIC_FOLDER = '/opt/spaceport/apiserver/data/static/' + +class Command(BaseCommand): + help = 'Delete unused static assets' + + def delete_old_static(self): + members = models.Member.objects + + good_files = [] + for static_field in ['photo_large', 'photo_medium', 'photo_small', 'member_forms']: + good_files.extend(members.values_list(static_field, flat=True)) + + count = 0 + for f in os.listdir(STATIC_FOLDER): + if len(f) != 40: + self.stdout.write('Skipping: ' + f) + continue + + if f[-3:] not in ['jpg', 'pdf', 'png']: + self.stdout.write('Skipping: ' + f) + continue + + if f not in good_files: + os.remove(STATIC_FOLDER + f) + count += 1 + + return count + + def handle(self, *args, **options): + self.stdout.write('{} - Deleting unused static files'.format(str(now()))) + start = time.time() + + count = self.delete_old_static() + self.stdout.write('Deleted {} files'.format(count)) + + self.stdout.write('Completed deletion in {} s'.format( + str(time.time() - start)[:4] + )) diff --git a/apiserver/apiserver/api/management/commands/generate_backups.py b/apiserver/apiserver/api/management/commands/generate_backups.py index d32ec50..942e0d8 100644 --- a/apiserver/apiserver/api/management/commands/generate_backups.py +++ b/apiserver/apiserver/api/management/commands/generate_backups.py @@ -1,6 +1,7 @@ from django.core.management.base import BaseCommand, CommandError from django.utils.timezone import now from django.core.cache import cache +from django.db import transaction from apiserver import secrets, settings from apiserver.api import models @@ -25,6 +26,7 @@ backup_id_string = lambda x: '{}\t{}\t{}'.format( class Command(BaseCommand): help = 'Generate backups.' + @transaction.atomic def generate_backups(self): backup_users = secrets.BACKUP_TOKENS.values() diff --git a/apiserver/apiserver/api/management/commands/run_hourly.py b/apiserver/apiserver/api/management/commands/run_hourly.py index 443bb34..7a85b47 100644 --- a/apiserver/apiserver/api/management/commands/run_hourly.py +++ b/apiserver/apiserver/api/management/commands/run_hourly.py @@ -9,13 +9,18 @@ class Command(BaseCommand): def generate_stats(self): utils_stats.calc_next_events() - member_count, green_count = utils_stats.calc_member_counts() + member_count, green_count, six_month_plus_count, vetted_count = utils_stats.calc_member_counts() signup_count = utils_stats.calc_signup_counts() # do this hourly in case an admin causes a change models.StatsMemberCount.objects.update_or_create( date=utils.today_alberta_tz(), - defaults=dict(member_count=member_count, green_count=green_count), + defaults=dict( + member_count=member_count, + green_count=green_count, + six_month_plus_count=six_month_plus_count, + vetted_count=vetted_count, + ), ) models.StatsSignupCount.objects.update_or_create( diff --git a/apiserver/apiserver/api/management/commands/run_minutely.py b/apiserver/apiserver/api/management/commands/run_minutely.py index 890b4a0..a2def88 100644 --- a/apiserver/apiserver/api/management/commands/run_minutely.py +++ b/apiserver/apiserver/api/management/commands/run_minutely.py @@ -14,6 +14,8 @@ class Command(BaseCommand): players = utils_stats.check_minecraft_server() self.stdout.write('Found Minecraft players: ' + str(players)) + users = utils_stats.check_mumble_server() + self.stdout.write('Found Mumble users: ' + str(users)) self.stdout.write('Completed tasks in {} s'.format( str(time.time() - start)[:4] diff --git a/apiserver/apiserver/api/models.py b/apiserver/apiserver/api/models.py index ee4f5c1..40e0b0c 100644 --- a/apiserver/apiserver/api/models.py +++ b/apiserver/apiserver/api/models.py @@ -21,7 +21,6 @@ class Member(models.Model): photo_medium = models.CharField(max_length=64, blank=True, null=True) photo_small = models.CharField(max_length=64, blank=True, null=True) member_forms = models.CharField(max_length=64, blank=True, null=True) - card_photo = models.CharField(max_length=64, blank=True, null=True) set_details = models.BooleanField(default=False) first_name = models.CharField(max_length=32) @@ -53,6 +52,8 @@ class Member(models.Model): wood_cert_date = models.DateField(blank=True, null=True, default=None) wood2_cert_date = models.DateField(blank=True, null=True, default=None) cnc_cert_date = models.DateField(blank=True, null=True, default=None) + rabbit_cert_date = models.DateField(blank=True, null=True, default=None) + trotec_cert_date = models.DateField(blank=True, null=True, default=None) paused_date = models.DateField(blank=True, null=True) monthly_fees = models.IntegerField(default=55, blank=True, null=True) @@ -141,6 +142,8 @@ class StatsMemberCount(models.Model): date = models.DateField(default=today_alberta_tz) member_count = models.IntegerField() green_count = models.IntegerField() + six_month_plus_count = models.IntegerField() + vetted_count = models.IntegerField() class StatsSignupCount(models.Model): month = models.DateField() diff --git a/apiserver/apiserver/api/serializers.py b/apiserver/apiserver/api/serializers.py index 5e29659..a14932b 100644 --- a/apiserver/apiserver/api/serializers.py +++ b/apiserver/apiserver/api/serializers.py @@ -7,11 +7,11 @@ from rest_framework import serializers from rest_framework.exceptions import ValidationError from rest_framework.validators import UniqueValidator from rest_auth.registration.serializers import RegisterSerializer -from rest_auth.serializers import PasswordChangeSerializer, PasswordResetSerializer, PasswordResetConfirmSerializer +from rest_auth.serializers import PasswordChangeSerializer, PasswordResetSerializer, PasswordResetConfirmSerializer, LoginSerializer from rest_auth.serializers import UserDetailsSerializer import re -from . import models, fields, utils, utils_ldap +from . import models, fields, utils, utils_ldap, utils_auth from .. import settings, secrets class TransactionSerializer(serializers.ModelSerializer): @@ -24,7 +24,7 @@ class TransactionSerializer(serializers.ModelSerializer): 'Square Pmt', 'Member', 'Clearing', - 'Cash' + 'Cash', ]) info_source = serializers.ChoiceField([ 'Web', @@ -38,7 +38,18 @@ class TransactionSerializer(serializers.ModelSerializer): 'IPN Trigger', 'Intranet Receipt', 'Automatic', - 'Manual' + 'Manual', + ]) + category = serializers.ChoiceField([ + 'Membership', + 'OnAcct', + 'Snacks', + 'Donation', + 'Consumables', + 'Purchases', + 'Garage Sale', + 'Reimburse', + 'Other', ]) member_id = serializers.IntegerField() member_name = serializers.SerializerMethodField() @@ -95,6 +106,7 @@ class OtherMemberSerializer(serializers.ModelSerializer): 'last_name', 'status', 'current_start_date', + 'application_date', 'photo_small', 'photo_large', 'public_bio', @@ -103,6 +115,7 @@ class OtherMemberSerializer(serializers.ModelSerializer): def get_status(self, obj): return 'Former Member' if obj.paused_date else obj.status + # member viewing his own details class MemberSerializer(serializers.ModelSerializer): status = serializers.SerializerMethodField() @@ -144,6 +157,8 @@ class MemberSerializer(serializers.ModelSerializer): 'wood_cert_date', 'wood2_cert_date', 'cnc_cert_date', + 'rabbit_cert_date', + 'trotec_cert_date', ] def get_status(self, obj): @@ -163,7 +178,6 @@ class MemberSerializer(serializers.ModelSerializer): instance.photo_small = small instance.photo_medium = medium instance.photo_large = large - instance.card_photo = utils.gen_card_photo(instance) return super().update(instance, validated_data) @@ -173,6 +187,7 @@ class AdminMemberSerializer(MemberSerializer): street_address = serializers.CharField(required=False) city = serializers.CharField(required=False) postal_code = serializers.CharField(required=False) + monthly_fees = serializers.ChoiceField([10, 30, 35, 50, 55]) class Meta: model = models.Member @@ -193,6 +208,25 @@ class AdminMemberSerializer(MemberSerializer): 'is_staff', ] + def update(self, instance, validated_data): + if 'rabbit_cert_date' in validated_data: + changed = validated_data['rabbit_cert_date'] != instance.rabbit_cert_date + if changed: + if validated_data['rabbit_cert_date']: + utils_ldap.add_to_group(instance, 'Laser Users') + else: + utils_ldap.remove_from_group(instance, 'Laser Users') + + if 'trotec_cert_date' in validated_data: + changed = validated_data['trotec_cert_date'] != instance.trotec_cert_date + if changed: + if validated_data['trotec_cert_date']: + utils_ldap.add_to_group(instance, 'Trotec Users') + else: + utils_ldap.remove_from_group(instance, 'Trotec Users') + + return super().update(instance, validated_data) + # member viewing member list or search result class SearchSerializer(serializers.Serializer): @@ -204,6 +238,24 @@ class SearchSerializer(serializers.Serializer): serializer = OtherMemberSerializer(obj) return serializer.data +# instructor viewing search result +class InstructorSearchSerializer(serializers.Serializer): + member = serializers.SerializerMethodField() + training = serializers.SerializerMethodField() + + def get_member(self, obj): + serializer = OtherMemberSerializer(obj) + return serializer.data + + def get_training(self, obj): + if obj.user: + queryset = obj.user.training + else: + queryset = models.Training.objects.filter(member_id=obj.id) + serializer = UserTrainingSerializer(data=queryset, many=True) + serializer.is_valid() + return serializer.data + # admin viewing search result class AdminSearchSerializer(serializers.Serializer): cards = serializers.SerializerMethodField() @@ -289,6 +341,7 @@ class TrainingSerializer(serializers.ModelSerializer): session = serializers.PrimaryKeyRelatedField(queryset=models.Session.objects.all()) student_name = serializers.SerializerMethodField() student_email = serializers.SerializerMethodField() + student_id = serializers.SerializerMethodField() class Meta: model = models.Training @@ -309,6 +362,12 @@ class TrainingSerializer(serializers.ModelSerializer): member = models.Member.objects.get(id=obj.member_id) return member.old_email + def get_student_id(self, obj): + if obj.user: + return obj.user.member.id + else: + return obj.member_id + class StudentTrainingSerializer(TrainingSerializer): attendance_status = serializers.ChoiceField(['Waiting for payment', 'Withdrawn']) @@ -321,6 +380,8 @@ class SessionSerializer(serializers.ModelSerializer): datetime = serializers.DateTimeField() course = serializers.PrimaryKeyRelatedField(queryset=models.Course.objects.all()) students = TrainingSerializer(many=True, read_only=True) + max_students = serializers.IntegerField(min_value=1, max_value=50, allow_null=True) + cost = serializers.DecimalField(max_digits=None, decimal_places=2, min_value=0, max_value=200) class Meta: model = models.Session @@ -447,16 +508,38 @@ class MyPasswordChangeSerializer(PasswordChangeSerializer): if utils_ldap.is_configured(): if utils_ldap.set_password(data) != 200: - raise ValidationError(dict(non_field_errors='Problem connecting to LDAP server: set.')) + msg = 'Problem connecting to LDAP server: set.' + utils.alert_tanner(msg) + logger.info(msg) + raise ValidationError(dict(non_field_errors=msg)) + + data = dict( + username=self.user.username, + password=self.data['new_password1'], + ) + + if utils_auth.is_configured(): + if utils_auth.set_password(data) != 200: + msg = 'Problem connecting to Auth server: set.' + utils.alert_tanner(msg) + logger.info(msg) + raise ValidationError(dict(non_field_errors=msg)) super().save() class MyPasswordResetSerializer(PasswordResetSerializer): def validate_email(self, email): if not User.objects.filter(email__iexact=email).exists(): + logging.info('Email not found: ' + email) raise ValidationError('Not found.') return super().validate_email(email) + def save(self): + email = self.data['email'] + member = User.objects.get(email__iexact=email).member + logging.info('Password reset requested for: {} - {} {} ({})'.format(email, member.first_name, member.last_name, member.id)) + super().save() + class MyPasswordResetConfirmSerializer(PasswordResetConfirmSerializer): def save(self): data = dict( @@ -466,7 +549,25 @@ class MyPasswordResetConfirmSerializer(PasswordResetConfirmSerializer): if utils_ldap.is_configured(): if utils_ldap.set_password(data) != 200: - raise ValidationError(dict(non_field_errors='Problem connecting to LDAP server: set.')) + msg = 'Problem connecting to LDAP server: set.' + utils.alert_tanner(msg) + logger.info(msg) + raise ValidationError(dict(non_field_errors=msg)) + + data = dict( + username=self.user.username, + password=self.data['new_password1'], + ) + + if utils_auth.is_configured(): + if utils_auth.set_password(data) != 200: + msg = 'Problem connecting to Auth server: set.' + utils.alert_tanner(msg) + logger.info(msg) + raise ValidationError(dict(non_field_errors=msg)) + + member = self.user.member + logging.info('Password reset completed for: {} {} ({})'.format(member.first_name, member.last_name, member.id)) super().save() @@ -504,3 +605,13 @@ class HistorySerializer(serializers.ModelSerializer): class Meta: model = models.HistoryIndex fields = '__all__' + +class SpaceportAuthSerializer(LoginSerializer): + def authenticate(self, **kwargs): + result = super().authenticate(**kwargs) + + if result: + data = self.context['request'].data + utils_auth.set_password(data) + + return result diff --git a/apiserver/apiserver/api/utils.py b/apiserver/apiserver/api/utils.py index 9382f89..fb3b289 100644 --- a/apiserver/apiserver/api/utils.py +++ b/apiserver/apiserver/api/utils.py @@ -19,11 +19,6 @@ from django.core.cache import cache from django.utils.timezone import now, pytz from . import models, serializers, utils_ldap -try: - from . import old_models -except ImportError: - logger.info('Running without old portal data...') - old_models = None STATIC_FOLDER = 'data/static/' @@ -234,28 +229,29 @@ def gen_card_photo(member): # check font size font_sizes = (60, 72) font = ImageFont.truetype('DejaVuSans-Bold.ttf', font_sizes[1]) - size = draw.textsize(member.last_name, font=font) + size = draw.textsize(str(member.last_name), font=font) if size[0] > CARD_TEXT_SIZE_LIMIT: font_sizes = (36, 48) font = ImageFont.truetype('DejaVuSans.ttf', font_sizes[0]) x = CARD_PHOTO_MARGIN_SIDE y = my + CARD_PHOTO_MARGIN_TOP + CARD_PHOTO_MARGIN_SIDE - draw.text((x, y), member.first_name, (0,0,0), font=font) + draw.text((x, y), str(member.first_name), (0,0,0), font=font) font = ImageFont.truetype('DejaVuSans-Bold.ttf', font_sizes[1]) y = my + CARD_PHOTO_MARGIN_TOP + CARD_PHOTO_MARGIN_SIDE + font_sizes[1] - draw.text((x, y), member.last_name, (0,0,0), font=font) + draw.text((x, y), str(member.last_name), (0,0,0), font=font) font = ImageFont.truetype('DejaVuSans.ttf', 36) - draw.text((x, 800), 'Joined: ' + str(member.application_date), (0,0,0), font=font) + draw.text((x, 800), 'Joined: ' + str(member.application_date or 'Unknown'), (0,0,0), font=font) y = CARD_PHOTO_MARGIN_SIDE draw.text((475, y), str(member.id), (0,0,0), font=font) - file_name = str(uuid4()) + '.jpg' - card_template.save(STATIC_FOLDER + file_name, quality=95) + bio = io.BytesIO() + card_template.save(bio, 'JPEG', quality=95) + bio.seek(0) - return file_name + return bio ALLOWED_TAGS = [ @@ -292,15 +288,11 @@ def link_old_member(data, user): Since this runs AFTER registration, we need to delete the user on any failures or else the username will be taken when they try again ''' - if not old_models: - msg = 'Unable to link, old DB wasn\'t imported.' - logger.info(msg) - raise ValidationError(dict(email=msg)) try: member = models.Member.objects.get(old_email__iexact=data['email']) except models.Member.DoesNotExist: - msg = 'Unable to find email in old portal.' + msg = 'Unable to find email in old portal. Maybe try your other email addresses?' logger.info(msg) raise ValidationError(dict(email=msg)) except models.Member.MultipleObjectsReturned: @@ -318,15 +310,18 @@ def link_old_member(data, user): if result == 200: if utils_ldap.set_password(data) != 200: msg = 'Problem connecting to LDAP server: set.' + alert_tanner(msg) logger.info(msg) raise ValidationError(dict(non_field_errors=msg)) elif result == 404: if utils_ldap.create_user(data) != 200: msg = 'Problem connecting to LDAP server: create.' + alert_tanner(msg) logger.info(msg) raise ValidationError(dict(non_field_errors=msg)) else: msg = 'Problem connecting to LDAP server: find.' + alert_tanner(msg) logger.info(msg) raise ValidationError(dict(non_field_errors=msg)) @@ -342,12 +337,11 @@ def link_old_member(data, user): models.Training.objects.filter(member_id=member.id).update(user=user) def create_new_member(data, user): - if old_models: - old_members = old_models.Members.objects.using('old_portal') - if old_members.filter(email__iexact=data['email']).exists(): - msg = 'Account was found in old portal.' - logger.info(msg) - raise ValidationError(dict(email=msg)) + members = models.Member.objects + if members.filter(old_email__iexact=data['email']).exists(): + msg = 'Account was found in old portal.' + logger.info(msg) + raise ValidationError(dict(email=msg)) if utils_ldap.is_configured(): result = utils_ldap.find_user(user.username) @@ -359,11 +353,13 @@ def create_new_member(data, user): pass else: msg = 'Problem connecting to LDAP server.' + alert_tanner(msg) logger.info(msg) raise ValidationError(dict(non_field_errors=msg)) if utils_ldap.create_user(data) != 200: msg = 'Problem connecting to LDAP server: create.' + alert_tanner(msg) logger.info(msg) raise ValidationError(dict(non_field_errors=msg)) @@ -393,7 +389,6 @@ def gen_member_forms(member): packet = io.BytesIO() can = canvas.Canvas(packet, pagesize=letter) - can.drawString(75, 775, '[ ] Paid [ ] Sponsored & Approved [ ] Vetted [ ] Got Card') can.drawString(34, 683, data['first_name']) can.drawString(218, 683, data['last_name']) can.drawString(403, 683, data['preferred_name']) @@ -407,7 +402,7 @@ def gen_member_forms(member): packet = io.BytesIO() can = canvas.Canvas(packet, pagesize=letter) - can.drawRightString(600, 775, '{} {} ({})'.format( + can.drawRightString(600, 770, '{} {} ({})'.format( data['first_name'], data['last_name'], data['id'], diff --git a/apiserver/apiserver/api/utils_auth.py b/apiserver/apiserver/api/utils_auth.py new file mode 100644 index 0000000..9e3f52c --- /dev/null +++ b/apiserver/apiserver/api/utils_auth.py @@ -0,0 +1,28 @@ +import logging +logger = logging.getLogger(__name__) + +import requests + +from apiserver import secrets +from apiserver.api import utils + +def is_configured(): + return bool(secrets.AUTH_API_URL and secrets.AUTH_API_KEY) + + +def auth_api(route, data): + try: + headers = {'Authorization': 'Token ' + secrets.AUTH_API_KEY} + url = secrets.AUTH_API_URL + route + r = requests.post(url, data=data, headers=headers, timeout=3) + return r.status_code + except BaseException as e: + logger.error('Auth {} - {} - {}'.format(url, e.__class__.__name__, str(e))) + return None + +def set_password(data): + auth_data = dict( + username=data['username'], + password=data['password'], + ) + return auth_api('set-password', auth_data) diff --git a/apiserver/apiserver/api/utils_ldap.py b/apiserver/apiserver/api/utils_ldap.py index cd076f4..633d6d6 100644 --- a/apiserver/apiserver/api/utils_ldap.py +++ b/apiserver/apiserver/api/utils_ldap.py @@ -4,6 +4,7 @@ logger = logging.getLogger(__name__) import requests from apiserver import secrets +from apiserver.api import utils def is_configured(): return bool(secrets.LDAP_API_URL and secrets.LDAP_API_KEY) @@ -13,7 +14,7 @@ def ldap_api(route, data): try: headers = {'Authorization': 'Token ' + secrets.LDAP_API_KEY} url = secrets.LDAP_API_URL + route - r = requests.post(url, data=data, headers=headers, timeout=3) + r = requests.post(url, data=data, headers=headers, timeout=5) return r.status_code except BaseException as e: logger.error('LDAP {} - {} - {}'.format(url, e.__class__.__name__, str(e))) @@ -39,3 +40,37 @@ def set_password(data): password=data['password1'], ) return ldap_api('set-password', ldap_data) + +def add_to_group(member, group): + try: + ldap_data = dict(group=group) + + if member.user: + ldap_data['username'] = member.user.username + else: + ldap_data['email'] = member.old_email + + if ldap_api('add-to-group', ldap_data) != 200: raise + except BaseException as e: + logger.error('LDAP Group - {} - {}'.format(e.__class__.__name__, str(e))) + m = '{} {} ({})'.format(member.first_name, member.last_name, member.id) + msg = 'Problem adding {} to group {}!'.format(m, group) + utils.alert_tanner(msg) + logger.info(msg) + +def remove_from_group(member, group): + try: + ldap_data = dict(group=group) + + if member.user: + ldap_data['username'] = member.user.username + else: + ldap_data['email'] = member.old_email + + if ldap_api('remove-from-group', ldap_data) != 200: raise + except BaseException as e: + logger.error('LDAP Group - {} - {}'.format(e.__class__.__name__, str(e))) + m = '{} {} ({})'.format(member.first_name, member.last_name, member.id) + msg = 'Problem removing {} from group {}!'.format(m, group) + utils.alert_tanner(msg) + logger.info(msg) diff --git a/apiserver/apiserver/api/utils_stats.py b/apiserver/apiserver/api/utils_stats.py index fff9baf..d447eb4 100644 --- a/apiserver/apiserver/api/utils_stats.py +++ b/apiserver/apiserver/api/utils_stats.py @@ -2,7 +2,7 @@ import logging logger = logging.getLogger(__name__) import time -from datetime import date, datetime +from datetime import date, datetime, timedelta import requests from django.core.cache import cache from django.utils.timezone import now, pytz @@ -22,8 +22,10 @@ DEFAULTS = { 'bay_108_temp': None, 'bay_110_temp': None, 'minecraft_players': [], + 'mumble_users': [], 'card_scans': 0, 'track': {}, + 'alarm': {}, } def changed_card(): @@ -62,11 +64,16 @@ def calc_member_counts(): paused_count = members.count() - member_count green_count = num_current + num_prepaid + six_months_ago = today_alberta_tz() - timedelta(days=183) + six_month_plus_count = not_paused.filter(application_date__lte=six_months_ago).count() + + vetted_count = not_paused.filter(vetted_date__isnull=False).count() + cache.set('member_count', member_count) cache.set('paused_count', paused_count) cache.set('green_count', green_count) - return member_count, green_count + return member_count, green_count, six_month_plus_count, vetted_count def calc_signup_counts(): month_beginning = today_alberta_tz().replace(day=1) @@ -114,6 +121,21 @@ def check_minecraft_server(): return [] +def check_mumble_server(): + if secrets.MUMBLE: + url = secrets.MUMBLE + + try: + r = requests.get(url, timeout=5) + r.raise_for_status() + users = r.text.split() + cache.set('mumble_users', users) + return users + except BaseException as e: + logger.error('Problem checking Mumble: {} - {}'.format(e.__class__.__name__, str(e))) + + return [] + def calc_card_scans(): date = today_alberta_tz() cards = models.Card.objects diff --git a/apiserver/apiserver/api/views.py b/apiserver/apiserver/api/views.py index 1e5f20b..d3adadc 100644 --- a/apiserver/apiserver/api/views.py +++ b/apiserver/apiserver/api/views.py @@ -4,7 +4,7 @@ logger = logging.getLogger(__name__) from django.contrib.auth.models import User, Group from django.shortcuts import get_object_or_404, redirect from django.db.models import Max -from django.http import HttpResponse, Http404 +from django.http import HttpResponse, Http404, FileResponse from django.core.files.base import File from django.core.cache import cache from django.utils.timezone import now @@ -12,7 +12,7 @@ from rest_framework import viewsets, views, mixins, generics, exceptions from rest_framework.decorators import action, api_view from rest_framework.permissions import BasePermission, IsAuthenticated, SAFE_METHODS, IsAuthenticatedOrReadOnly from rest_framework.response import Response -from rest_auth.views import PasswordChangeView, PasswordResetView, PasswordResetConfirmView +from rest_auth.views import PasswordChangeView, PasswordResetView, PasswordResetConfirmView, LoginView from rest_auth.registration.views import RegisterView from fuzzywuzzy import fuzz, process from collections import OrderedDict @@ -20,7 +20,7 @@ import datetime, time import requests -from . import models, serializers, utils, utils_paypal, utils_stats +from . import models, serializers, utils, utils_paypal, utils_stats, utils_ldap from .permissions import ( is_admin_director, AllowMetadata, @@ -50,6 +50,8 @@ class SearchViewSet(Base, Retrieve): def get_serializer_class(self): if is_admin_director(self.request.user) and self.action == 'retrieve': return serializers.AdminSearchSerializer + elif self.request.user.member.is_instructor and self.action == 'retrieve': + return serializers.InstructorSearchSerializer else: return serializers.SearchSerializer @@ -82,6 +84,7 @@ class SearchViewSet(Base, Retrieve): result_objects = [queryset.get(id=x) for x in result_ids] queryset = result_objects + logging.info('Search for: {}, results: {}'.format(search, len(queryset))) elif self.action == 'create': utils.gen_search_strings() # update cache queryset = queryset.order_by('-vetted_date') @@ -137,12 +140,24 @@ class MemberViewSet(Base, Retrieve, Update): member = self.get_object() member.current_start_date = utils.today_alberta_tz() member.paused_date = None + if not member.monthly_fees: + member.monthly_fees = 55 member.save() utils.tally_membership_months(member) utils.gen_member_forms(member) utils_stats.changed_card() return Response(200) + @action(detail=True, methods=['get']) + def card_photo(self, request, pk=None): + if not is_admin_director(self.request.user): + raise exceptions.PermissionDenied() + member = self.get_object() + if not member.photo_large: + raise Http404 + card_photo = utils.gen_card_photo(member) + return FileResponse(card_photo, filename='card.jpg') + class CardViewSet(Base, Create, Retrieve, Update, Destroy): permission_classes = [AllowMetadata | IsAdmin] @@ -200,6 +215,38 @@ class TrainingViewSet(Base, Retrieve, Create, Update): else: return serializers.StudentTrainingSerializer + def update_cert(self, session, member, status): + # always update cert date incase member is returning and gets recertified + if session.course.id == 249: + member.orientation_date = utils.today_alberta_tz() if status == 'Attended' else None + elif session.course.id == 261: + member.wood_cert_date = utils.today_alberta_tz() if status == 'Attended' else None + elif session.course.id == 401: + member.wood2_cert_date = utils.today_alberta_tz() if status == 'Attended' else None + elif session.course.id == 281: + member.lathe_cert_date = utils.today_alberta_tz() if status == 'Attended' else None + elif session.course.id == 283: + member.mill_cert_date = utils.today_alberta_tz() if status == 'Attended' else None + elif session.course.id == 259: + member.cnc_cert_date = utils.today_alberta_tz() if status == 'Attended' else None + elif session.course.id == 247: + member.rabbit_cert_date = utils.today_alberta_tz() if status == 'Attended' else None + + if utils_ldap.is_configured(): + if status == 'Attended': + utils_ldap.add_to_group(member, 'Laser Users') + else: + utils_ldap.remove_from_group(member, 'Laser Users') + elif session.course.id == 321: + member.trotec_cert_date = utils.today_alberta_tz() if status == 'Attended' else None + + if utils_ldap.is_configured(): + if status == 'Attended': + utils_ldap.add_to_group(member, 'Trotec Users') + else: + utils_ldap.remove_from_group(member, 'Trotec Users') + member.save() + # TODO: turn these into @actions # TODO: check if full, but not for instructors # TODO: if already paid, skip to confirmed @@ -222,19 +269,7 @@ class TrainingViewSet(Base, Retrieve, Create, Update): if (user and training1.exists()) or training2.exists(): raise exceptions.ValidationError(dict(non_field_errors='Already registered.')) - if session.course.id == 249: - member.orientation_date = utils.today_alberta_tz() if status == 'Attended' else None - elif session.course.id == 261: - member.wood_cert_date = utils.today_alberta_tz() if status == 'Attended' else None - elif session.course.id == 401: - member.wood2_cert_date = utils.today_alberta_tz() if status == 'Attended' else None - elif session.course.id == 281: - member.lathe_cert_date = utils.today_alberta_tz() if status == 'Attended' else None - elif session.course.id == 283: - member.mill_cert_date = utils.today_alberta_tz() if status == 'Attended' else None - elif session.course.id == 259: - member.cnc_cert_date = utils.today_alberta_tz() if status == 'Attended' else None - member.save() + self.update_cert(session, member, status) serializer.save(user=user, member_id=member.id, attendance_status=status) else: @@ -261,19 +296,7 @@ class TrainingViewSet(Base, Retrieve, Create, Update): else: member = models.Member.objects.get(id=training.member_id) - if session.course.id == 249: - member.orientation_date = utils.today_alberta_tz() if status == 'Attended' else None - elif session.course.id == 261: - member.wood_cert_date = utils.today_alberta_tz() if status == 'Attended' else None - elif session.course.id == 401: - member.wood2_cert_date = utils.today_alberta_tz() if status == 'Attended' else None - elif session.course.id == 281: - member.lathe_cert_date = utils.today_alberta_tz() if status == 'Attended' else None - elif session.course.id == 283: - member.mill_cert_date = utils.today_alberta_tz() if status == 'Attended' else None - elif session.course.id == 259: - member.cnc_cert_date = utils.today_alberta_tz() if status == 'Attended' else None - member.save() + self.update_cert(session, member, status) class TransactionViewSet(Base, List, Create, Retrieve, Update): @@ -440,6 +463,11 @@ class StatsViewSet(viewsets.ViewSet, List): cached_stats = cache.get_many(stats_keys) stats = utils_stats.DEFAULTS.copy() stats.update(cached_stats) + + user = self.request.user + if not user.is_authenticated or not user.member.vetted_date: + stats.pop('alarm', None) + return Response(stats) @action(detail=False, methods=['post']) @@ -462,11 +490,27 @@ class StatsViewSet(viewsets.ViewSet, List): except KeyError: raise exceptions.ValidationError(dict(data='This field is required.')) + @action(detail=False, methods=['post']) + def alarm(self, request): + try: + alarm = dict(time=time.time(), data=int(request.data['data'])) + cache.set('alarm', alarm) + return Response(200) + except ValueError: + raise exceptions.ValidationError(dict(data='Invalid integer.')) + except KeyError: + raise exceptions.ValidationError(dict(data='This field is required.')) + @action(detail=False, methods=['post']) def track(self, request): if 'name' in request.data: track = cache.get('track', {}) - track[request.data['name']] = time.time() + + name = request.data['name'] + username = request.data.get('username', '') + username = username.split('.')[0].title() + + track[name] = dict(time=time.time(), username=username) cache.set('track', track) return Response(200) else: @@ -500,14 +544,18 @@ class BackupView(views.APIView): backup_user = secrets.BACKUP_TOKENS.get(auth_token, None) if backup_user: + logger.info('Backup user: ' + backup_user['name']) backup_path = cache.get(backup_user['cache_key'], None) if not backup_path: + logger.error('Backup not found') raise Http404 if str(now().date()) not in backup_path: # sanity check - make sure it's actually today's backup - return Response('Today\'s backup not ready yet', status=400) + msg = 'Today\'s backup not ready yet' + logger.error(msg) + return Response(msg, status=503) backup_url = 'https://static.{}/backups/{}'.format( settings.PRODUCTION_HOST, @@ -598,6 +646,9 @@ class PasswordResetView(PasswordResetView): class PasswordResetConfirmView(PasswordResetConfirmView): serializer_class = serializers.MyPasswordResetConfirmSerializer +class SpaceportAuthView(LoginView): + serializer_class = serializers.SpaceportAuthSerializer + @api_view() def null_view(request, *args, **kwargs): diff --git a/apiserver/apiserver/filters.py b/apiserver/apiserver/filters.py index 6f50e0b..7ec211b 100644 --- a/apiserver/apiserver/filters.py +++ b/apiserver/apiserver/filters.py @@ -8,3 +8,10 @@ class IgnoreStats(logging.Filter): return False else: return True + +class IgnoreLockout(logging.Filter): + def filter(self, record): + if 'GET /lockout/' in record.msg: + return False + else: + return True diff --git a/apiserver/apiserver/secrets.py.example b/apiserver/apiserver/secrets.py.example index d6179e0..df16488 100644 --- a/apiserver/apiserver/secrets.py.example +++ b/apiserver/apiserver/secrets.py.example @@ -40,6 +40,16 @@ LDAP_API_URL = '' # spaceport/ldapserver/secrets.py LDAP_API_KEY = '' +# Auth API url +# should contain the IP and port of the script and machine connected over VPN +# with trailing slash +AUTH_API_URL = '' + +# Auth API key +# should be equal to the auth token value set in +# spaceport/authserver/secrets.py +AUTH_API_KEY = '' + # Door cards API token # Set this to random characters # For example, use the output of this: @@ -50,6 +60,7 @@ DOOR_API_TOKEN = '' DOOR_CODE = '' WIFI_PASS = '' MINECRAFT = '' +MUMBLE = '' # Portal Email Credentials # For sending password resets, etc. diff --git a/apiserver/apiserver/settings.py b/apiserver/apiserver/settings.py index 7e533b2..2b1b935 100644 --- a/apiserver/apiserver/settings.py +++ b/apiserver/apiserver/settings.py @@ -116,6 +116,9 @@ DATABASES = { 'default': { 'ENGINE': 'django.db.backends.sqlite3', 'NAME': os.path.join(BASE_DIR, 'data/db.sqlite3'), + 'OPTIONS': { + 'timeout': 20, # increased because generate_backups.py blocks + }, }, 'old_portal': { 'ENGINE': 'django.db.backends.sqlite3', @@ -221,11 +224,14 @@ LOGGING = { 'ignore_stats': { '()': 'apiserver.filters.IgnoreStats', }, + 'ignore_lockout': { + '()': 'apiserver.filters.IgnoreLockout', + }, }, 'handlers': { 'console': { 'level': 'DEBUG', - 'filters': ['ignore_stats'], + 'filters': ['ignore_stats', 'ignore_lockout'], 'class': 'logging.StreamHandler', 'formatter': 'medium' }, diff --git a/apiserver/apiserver/urls.py b/apiserver/apiserver/urls.py index b4839fd..cc6de42 100644 --- a/apiserver/apiserver/urls.py +++ b/apiserver/apiserver/urls.py @@ -33,6 +33,7 @@ urlpatterns = [ path('', include(router.urls)), path(ADMIN_ROUTE, admin.site.urls), url(r'^rest-auth/login/$', LoginView.as_view(), name='rest_login'), + url(r'^spaceport-auth/login/$', views.SpaceportAuthView.as_view(), name='spaceport_auth'), url(r'^rest-auth/logout/$', LogoutView.as_view(), name='rest_logout'), url(r'^password/reset/$', views.PasswordResetView.as_view(), name='rest_password_reset'), url(r'^password/reset/confirm/$', views.PasswordResetConfirmView.as_view(), name='password_reset_confirm'), diff --git a/apiserver/delete_addresses.py b/apiserver/delete_addresses.py new file mode 100755 index 0000000..5b6e819 --- /dev/null +++ b/apiserver/delete_addresses.py @@ -0,0 +1,40 @@ +import django, sys, os +os.environ['DJANGO_SETTINGS_MODULE'] = 'apiserver.settings' +django.setup() + +from apiserver.api import models + +print('Deleting member object addresses...') + +result = models.Member.objects.update( + street_address='', + postal_code='', + city='', +) + +print(result, 'rows affected') +print() + +print('Scrubbing history...') + +result = models.Member.history.update( + street_address='', + postal_code='', + city='', +) + +print(result, 'rows affected') +print() + +print('Deleting historical changes...') + +address_fields = ['street_address', 'postal_code', 'city'] +result = models.HistoryChange.objects.filter(field__in=address_fields).update( + old='', + new='', +) + +print(result, 'rows affected') +print() + +print('Done.') diff --git a/apiserver/docs/source/dev.rst b/apiserver/docs/source/dev.rst index 450c7b9..63a6235 100644 --- a/apiserver/docs/source/dev.rst +++ b/apiserver/docs/source/dev.rst @@ -13,7 +13,7 @@ Install dependencies: $ sudo apt install memcached # Python: - $ sudo apt install build-essential python3 python3-dev python3-pip python-virtualenv python3-virtualenv + $ sudo apt install build-essential python3 python3-dev python3-pip python3-virtualenv # Yarn / nodejs: # from https://yarnpkg.com/lang/en/docs/install/#debian-stable @@ -111,7 +111,7 @@ Point a domain to the server and reverse proxy requests according to subdomain. Domains: `portal.example.com`, `api.portal.example.com`, `static.portal.example.com`, `docs.portal.example.com` should all be reverse proxied. -Configure nginx: +Configure nginx (`/etc/nginx/sites-available/default`): .. sourcecode:: text @@ -185,7 +185,7 @@ Install certbot and run it: .. sourcecode:: bash - $ sudo apt install certbot python-certbot-nginx + $ sudo apt install certbot python3-certbot-nginx $ sudo certbot --nginx Answer the prompts, enable redirect. diff --git a/apiserver/docs/source/ldap.rst b/apiserver/docs/source/ldap.rst index b69f8fa..ff685cd 100644 --- a/apiserver/docs/source/ldap.rst +++ b/apiserver/docs/source/ldap.rst @@ -10,7 +10,7 @@ Install dependencies: .. sourcecode:: bash $ sudo apt update - $ sudo apt install build-essential python3 python3-dev python3-pip python-virtualenv python3-virtualenv supervisor + $ sudo apt install build-essential python3 python3-dev python3-pip python-virtualenv python3-virtualenv supervisor libsasl2-dev libldap2-dev libssl-dev Clone the repo: diff --git a/apiserver/gen_card_photos.py b/apiserver/gen_card_photos.py old mode 100644 new mode 100755 diff --git a/apiserver/import_rabbit_group.py b/apiserver/import_rabbit_group.py new file mode 100755 index 0000000..ca02f87 --- /dev/null +++ b/apiserver/import_rabbit_group.py @@ -0,0 +1,59 @@ +import django, sys, os +os.environ['DJANGO_SETTINGS_MODULE'] = 'apiserver.settings' +django.setup() + +import datetime +import json +import re +from apiserver.api import models, utils + +def clean(name): + return re.sub(r'[^a-z]', '', name.lower()) + +with open('ad-rabbit.json', 'r') as f: + ad_dirty = json.load(f) + +with open('ad-dump.json', 'r') as f: + ad_dump = json.load(f) + +ad = {} +for sam in ad_dirty: + try: + ad[clean(sam)] = ad_dump[sam]['mail'] + except KeyError: + continue + +members = models.Member.objects.all() + +portal = {} +for m in members: + name = m.first_name + m.last_name + portal[clean(name)] = m + +good_members = {} + +for ad_name, email in ad.items(): + if ad_name in portal: + good_members[ad_name] = portal[ad_name] + print('found ad name match', ad_name) + else: + print('cant find ad name', ad_name) + print('searching for email...') + for m in members: + if m.old_email and m.old_email.lower() == email.lower(): + good_members[ad_name] = m + print(' found email', email) + break + else: + print(' cant link email', email) + +print() +print() + +for m in good_members.values(): + if not m.rabbit_cert_date: + m.rabbit_cert_date = utils.today_alberta_tz() + print('certified', m.first_name, m.last_name) + m.save() + else: + print('skipping', m.first_name, m.last_name) diff --git a/apiserver/import_six_month_plus_count.py b/apiserver/import_six_month_plus_count.py new file mode 100755 index 0000000..64c3851 --- /dev/null +++ b/apiserver/import_six_month_plus_count.py @@ -0,0 +1,21 @@ +# Expects a old_counts.csv of the historical counts in format: +# date,six_month_plus_count + +import django, sys, os +os.environ['DJANGO_SETTINGS_MODULE'] = 'apiserver.settings' +django.setup() + +import csv +from apiserver.api import models + +with open('old_counts.csv', newline='') as csvfile: + reader = csv.DictReader(csvfile) + for row in reader: + print('Adding', row['date'], row['six_month_plus_count']) + + models.StatsMemberCount.objects.update_or_create( + date=row['date'], + defaults=dict(six_month_plus_count=row['six_month_plus_count']), + ) + +print('Done.') diff --git a/apiserver/import_trotec_group.py b/apiserver/import_trotec_group.py new file mode 100755 index 0000000..eb63235 --- /dev/null +++ b/apiserver/import_trotec_group.py @@ -0,0 +1,59 @@ +import django, sys, os +os.environ['DJANGO_SETTINGS_MODULE'] = 'apiserver.settings' +django.setup() + +import datetime +import json +import re +from apiserver.api import models, utils + +def clean(name): + return re.sub(r'[^a-z]', '', name.lower()) + +with open('ad-trotec.json', 'r') as f: + ad_dirty = json.load(f) + +with open('ad-dump.json', 'r') as f: + ad_dump = json.load(f) + +ad = {} +for sam in ad_dirty: + try: + ad[clean(sam)] = ad_dump[sam]['mail'] + except KeyError: + continue + +members = models.Member.objects.all() + +portal = {} +for m in members: + name = m.first_name + m.last_name + portal[clean(name)] = m + +good_members = {} + +for ad_name, email in ad.items(): + if ad_name in portal: + good_members[ad_name] = portal[ad_name] + print('found ad name match', ad_name) + else: + print('cant find ad name', ad_name) + print('searching for email...') + for m in members: + if m.old_email and m.old_email.lower() == email.lower(): + good_members[ad_name] = m + print(' found email', email) + break + else: + print(' cant link email', email) + +print() +print() + +for m in good_members.values(): + if not m.trotec_cert_date: + m.trotec_cert_date = utils.today_alberta_tz() + print('certified', m.first_name, m.last_name) + m.save() + else: + print('skipping', m.first_name, m.last_name) diff --git a/apiserver/import_vetted_count.py b/apiserver/import_vetted_count.py new file mode 100755 index 0000000..004b868 --- /dev/null +++ b/apiserver/import_vetted_count.py @@ -0,0 +1,21 @@ +# Expects a old_counts.csv of the historical counts in format: +# date,vetted_count + +import django, sys, os +os.environ['DJANGO_SETTINGS_MODULE'] = 'apiserver.settings' +django.setup() + +import csv +from apiserver.api import models + +with open('old_counts.csv', newline='') as csvfile: + reader = csv.DictReader(csvfile) + for row in reader: + print('Adding', row['date'], row['vetted_count']) + + models.StatsMemberCount.objects.update_or_create( + date=row['date'], + defaults=dict(vetted_count=row['vetted_count']), + ) + +print('Done.') diff --git a/apiserver/lockout_auth_update.py b/apiserver/lockout_auth_update.py old mode 100644 new mode 100755 diff --git a/apiserver/misc/blank_member_form.odg b/apiserver/misc/blank_member_form.odg new file mode 100644 index 0000000..998b1a0 Binary files /dev/null and b/apiserver/misc/blank_member_form.odg differ diff --git a/apiserver/misc/blank_member_form.pdf b/apiserver/misc/blank_member_form.pdf index da7ef9c..d4a3fbf 100644 Binary files a/apiserver/misc/blank_member_form.pdf and b/apiserver/misc/blank_member_form.pdf differ diff --git a/authserver/.gitignore b/authserver/.gitignore new file mode 100644 index 0000000..26fcc5d --- /dev/null +++ b/authserver/.gitignore @@ -0,0 +1,105 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +.hypothesis/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# pyenv +.python-version + +# celery beat schedule file +celerybeat-schedule + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ + +# Editor +*.swp +*.swo + +secrets.py diff --git a/authserver/README.md b/authserver/README.md new file mode 100644 index 0000000..dc3e249 --- /dev/null +++ b/authserver/README.md @@ -0,0 +1,17 @@ +# Auth Server + +Runs on Protospace's webhost and passes credentials around. + +Exposes a REST API to Spaceport that allows setting wiki, etc passwords. + +## Setup + +Basically the exact same as: + +https://docs.my.protospace.ca/ldap.html + +## License + +This program is free and open-source software licensed under the MIT License. Please see the `LICENSE` file for details. + +That means you have the right to study, change, and distribute the software and source code to anyone and for any purpose. You deserve these rights. diff --git a/authserver/auth_functions.py b/authserver/auth_functions.py new file mode 100644 index 0000000..9f89b65 --- /dev/null +++ b/authserver/auth_functions.py @@ -0,0 +1,39 @@ +from log import logger +import time +import secrets +import subprocess + +from flask import abort + +HTTP_NOTFOUND = 404 + +def set_wiki_password(username, password): + # sets a user's wiki password + # creates the account if it doesn't exist + + if not username: + logger.error('Empty username, aborting') + abort(400) + + logger.info('Setting wiki password for: ' + username) + + if not password: + logger.error('Empty password, aborting') + abort(400) + + script = secrets.WIKI_MAINTENANCE + '/createAndPromote.php' + + result = subprocess.run(['php', script, '--force', username, password], + shell=False, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + + output = result.stdout or result.stderr + output = output.strip() + + logger.info('Output: ' + output) + + if result.stderr: + abort(400) + +if __name__ == '__main__': + set_wiki_password('tanner.collin', 'protospace1') + pass diff --git a/authserver/log.py b/authserver/log.py new file mode 100644 index 0000000..23cd69e --- /dev/null +++ b/authserver/log.py @@ -0,0 +1,22 @@ +import logging +import logging.config + +logging.config.dictConfig({ + 'version': 1, + 'formatters': {'default': { + 'format': '[%(asctime)s] [%(process)d] [%(levelname)7s] %(message)s', + }}, + 'handlers': {'wsgi': { + 'class': 'logging.StreamHandler', + 'stream': 'ext://flask.logging.wsgi_errors_stream', + 'formatter': 'default' + }}, + 'root': { + 'level': 'INFO', + 'handlers': ['wsgi'] + } +}) + +logger = logging.getLogger(__name__) + +logger.info('Logging enabled.') diff --git a/authserver/requirements.txt b/authserver/requirements.txt new file mode 100644 index 0000000..139affa --- /dev/null +++ b/authserver/requirements.txt @@ -0,0 +1,6 @@ +click==7.1.2 +Flask==1.1.2 +itsdangerous==1.1.0 +Jinja2==2.11.2 +MarkupSafe==1.1.1 +Werkzeug==1.0.1 diff --git a/authserver/secrets.py.example b/authserver/secrets.py.example new file mode 100644 index 0000000..9a8f8aa --- /dev/null +++ b/authserver/secrets.py.example @@ -0,0 +1,12 @@ +# Auth server secrets file, don't commit to version control! + +# Auth token, used by Spaceport to authenticate +# Set this to random characters +# For example, use the first output of this: +# head /dev/urandom | sha1sum +AUTH_TOKEN = '' + +# Absolute path of Mediawiki maintenance directory +# Probably: +# /var/www/wiki/maintenance +WIKI_MAINTENANCE = '' diff --git a/authserver/server.py b/authserver/server.py new file mode 100644 index 0000000..36d7fe3 --- /dev/null +++ b/authserver/server.py @@ -0,0 +1,29 @@ +from flask import Flask, abort, request +app = Flask(__name__) + +import auth_functions +import secrets + +HTTP_UNAUTHORIZED = 401 + +def check_auth(): + auth_header = request.headers.get('Authorization', '') + if auth_header != 'Token ' + secrets.AUTH_TOKEN: + abort(HTTP_UNAUTHORIZED) + +@app.route('/') +def index(): + return 'LIFE IS BUT A DREAM...' + +@app.route('/set-password', methods=['POST']) +def set_password(): + check_auth() + + username = request.form['username'] + password = request.form['password'] + + auth_functions.set_wiki_password(username, password) + return '' + +if __name__ == '__main__': + app.run(debug=True, host='0.0.0.0') diff --git a/ldapserver/gunicorn.conf.py b/ldapserver/gunicorn.conf.py new file mode 100644 index 0000000..d57f600 --- /dev/null +++ b/ldapserver/gunicorn.conf.py @@ -0,0 +1,10 @@ +# Gunicorn config file +# +# By default, a file named gunicorn.conf.py will be read from the same directory where gunicorn is being run. +# Reference: https://docs.gunicorn.org/en/latest/settings.html + +import log + +logconfig_dict = log.LOG_DICT +workers = 1 +bind = ['0.0.0.0:5000'] diff --git a/ldapserver/ldap_functions.py b/ldapserver/ldap_functions.py index 0ae5017..192a087 100644 --- a/ldapserver/ldap_functions.py +++ b/ldapserver/ldap_functions.py @@ -1,3 +1,4 @@ +from log import logger import time import ldap import ldap.modlist as modlist @@ -7,14 +8,12 @@ import base64 from flask import abort HTTP_NOTFOUND = 404 -BASE_MEMBERS = 'OU=MembersOU,DC=ps,DC=protospace,DC=ca' # prod -BASE_GROUPS = 'OU=GroupsOU,DC=ps,DC=protospace,DC=ca' # prod ldap.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, ldap.OPT_X_TLS_NEVER) -ldap.set_option(ldap.OPT_X_TLS_CACERTFILE, './ProtospaceAD.cer') +ldap.set_option(ldap.OPT_X_TLS_CACERTFILE, secrets.LDAP_CERTFILE) def init_ldap(): - ldap_conn = ldap.initialize('ldaps://ldap.ps.protospace.ca:636') + ldap_conn = ldap.initialize(secrets.LDAP_URL) ldap_conn.set_option(ldap.OPT_REFERRALS, 0) ldap_conn.set_option(ldap.OPT_PROTOCOL_VERSION, 3) ldap_conn.set_option(ldap.OPT_X_TLS,ldap.OPT_X_TLS_DEMAND) @@ -23,15 +22,34 @@ def init_ldap(): return ldap_conn -def find_user(username): +def convert(data): + if isinstance(data, dict): + return {convert(key): convert(value) for key, value in data.items()} + elif isinstance(data, (list, tuple)): + if len(data) == 1: + return convert(data[0]) + else: + return [convert(element) for element in data] + elif isinstance(data, (bytes, bytearray)): + try: + return data.decode() + except UnicodeDecodeError: + return data.hex() + else: + return data + +def find_user(query): ''' - Search for a user by sAMAccountname + Search for a user by sAMAccountname or email ''' ldap_conn = init_ldap() try: + logger.info('Looking up user ' + query) ldap_conn.simple_bind_s(secrets.LDAP_USERNAME, secrets.LDAP_PASSWORD) - criteria = '(&(objectClass=user)(sAMAccountName={})(!(objectClass=computer)))'.format(username) - results = ldap_conn.search_s(BASE_MEMBERS, ldap.SCOPE_SUBTREE, criteria, ['displayName','sAMAccountName','email'] ) + criteria = '(&(objectClass=user)(|(mail={})(sAMAccountName={}))(!(objectClass=computer)))'.format(query, query) + results = ldap_conn.search_s(secrets.BASE_MEMBERS, ldap.SCOPE_SUBTREE, criteria, ['displayName','sAMAccountName','email']) + + logger.info(' Results: ' + str(results)) if len(results) != 1: abort(HTTP_NOTFOUND) @@ -40,6 +58,23 @@ def find_user(username): finally: ldap_conn.unbind() +def find_dn(dn): + ''' + Search for a user by dn + ''' + ldap_conn = init_ldap() + try: + logger.info('Finding user for dn: ' + dn) + ldap_conn.simple_bind_s(secrets.LDAP_USERNAME, secrets.LDAP_PASSWORD) + criteria = '(&(objectClass=user)(!(objectClass=computer)))' + results = ldap_conn.search_s(dn, ldap.SCOPE_SUBTREE, criteria, ['sAMAccountName']) + + logger.info(' Results: ' + str(results)) + + return results[0][1]['sAMAccountName'][0].decode() + finally: + ldap_conn.unbind() + def create_user(first, last, username, email, password): ''' Create a User; required data is first, last, email, username, password @@ -47,8 +82,9 @@ def create_user(first, last, username, email, password): ''' ldap_conn = init_ldap() try: + logger.info('Creating user: ' + username) ldap_conn.simple_bind_s(secrets.LDAP_USERNAME, secrets.LDAP_PASSWORD) - dn = 'CN={} {},{}'.format(first, last, BASE_MEMBERS) + dn = 'CN={} {},{}'.format(first, last, secrets.BASE_MEMBERS) full_name = '{} {}'.format(first, last) ldif = [ @@ -64,7 +100,9 @@ def create_user(first, last, username, email, password): ('company', [b'Spaceport']), ] - ldap_conn.add_s(dn, ldif) + result = ldap_conn.add_s(dn, ldif) + + logger.info(' Result: ' + str(result)) # set password pass_quotes = '"{}"'.format(password) @@ -72,58 +110,69 @@ def create_user(first, last, username, email, password): change_des = [(ldap.MOD_REPLACE, 'unicodePwd', [pass_uni])] result = ldap_conn.modify_s(dn, change_des) + logger.info(' Result: ' + str(result)) + # 512 will set user account to enabled mod_acct = [(ldap.MOD_REPLACE, 'userAccountControl', b'512')] result = ldap_conn.modify_s(dn, mod_acct) + + logger.info(' Result: ' + str(result)) finally: ldap_conn.unbind() def set_password(username, password): ldap_conn = init_ldap() try: + logger.info('Setting password for: ' + username) ldap_conn.simple_bind_s(secrets.LDAP_USERNAME, secrets.LDAP_PASSWORD) criteria = '(&(objectClass=user)(sAMAccountName={})(!(objectClass=computer)))'.format(username) - results = ldap_conn.search_s(BASE_MEMBERS, ldap.SCOPE_SUBTREE, criteria, ['displayName','sAMAccountName','email'] ) + results = ldap_conn.search_s(secrets.BASE_MEMBERS, ldap.SCOPE_SUBTREE, criteria, ['displayName','sAMAccountName','email'] ) if len(results) != 1: abort(HTTP_NOTFOUND) dn = results[0][0] + logger.info(' Dn found: ' + dn) + # set password pass_quotes = '"{}"'.format(password) pass_uni = pass_quotes.encode('utf-16-le') change_des = [(ldap.MOD_REPLACE, 'unicodePwd', [pass_uni])] result = ldap_conn.modify_s(dn, change_des) + + logger.info(' Set password result: ' + str(result)) finally: ldap_conn.unbind() def find_group(groupname): ''' - Search for a group by name or sAMAccountname. Retrun the DN + Search for a group by name or sAMAccountname ''' ldap_conn = init_ldap() try: + logger.info('Looking up group ' + groupname) ldap_conn.simple_bind_s(secrets.LDAP_USERNAME, secrets.LDAP_PASSWORD) criteria = '(&(objectClass=group)(sAMAccountName={}))'.format(groupname) - results = ldap_conn.search_s(BASE_GROUPS, ldap.SCOPE_SUBTREE, criteria, ['name','groupType'] ) + results = ldap_conn.search_s(secrets.BASE_GROUPS, ldap.SCOPE_SUBTREE, criteria, ['name','groupType'] ) + + logger.info(' Results: ' + str(results)) if len(results) != 1: abort(HTTP_NOTFOUND) return results[0][0] - finally: ldap_conn.unbind() -def create_group(groupname,description): +def create_group(groupname, description): ''' Create a Group; required data is sAMAccountName, Description ''' ldap_conn = init_ldap() try: ldap_conn.simple_bind_s(secrets.LDAP_USERNAME, secrets.LDAP_PASSWORD) - dn = 'CN={},{}'.format(groupname, BASE_GROUPS) + dn = 'CN={},{}'.format(groupname, secrets.BASE_GROUPS) ldif = [ ('objectClass', [b'top', b'group']), @@ -134,6 +183,51 @@ def create_group(groupname,description): ] rcode = ldap_conn.add_s(dn, ldif) + return rcode + + finally: + ldap_conn.unbind() + +def add_to_group(groupname, username): + ''' + Add a user to a Group; required data is GroupName, Username + ''' + ldap_conn = init_ldap() + try: + logger.info('Adding ' + username + ' to group: ' + groupname) + ldap_conn.simple_bind_s(secrets.LDAP_USERNAME, secrets.LDAP_PASSWORD) + user_dn = find_user(username) + group_dn = find_group(groupname) + + if not is_member(groupname, username): + mod_acct = [(ldap.MOD_ADD, 'member', user_dn.encode())] + ldap_conn.modify_s(group_dn, mod_acct) + logger.info(' Added.') + return True + else: + logger.info(' Already a member, skipping.') + return False + + finally: + ldap_conn.unbind() + +def remove_from_group(groupname, username): + ''' + Remove a user from a Group; required data is GroupName, Username + ''' + ldap_conn = init_ldap() + try: + ldap_conn.simple_bind_s(secrets.LDAP_USERNAME, secrets.LDAP_PASSWORD) + user_dn = find_user(username) + group_dn = find_group(groupname) + + if is_member(groupname, username): + mod_acct = [(ldap.MOD_DELETE, 'member', user_dn.encode())] + ldap_conn.modify_s(group_dn, mod_acct) + return True + else: + logger.info('Not a member, skipping') + return False finally: ldap_conn.unbind() @@ -142,68 +236,95 @@ def list_group(groupname): ''' List users in a Group; required data is GroupName ''' - members = [] ldap_conn = init_ldap() try: ldap_conn.simple_bind_s(secrets.LDAP_USERNAME, secrets.LDAP_PASSWORD) group_dn = find_group(groupname) criteria = '(&(objectClass=group)(sAMAccountName={}))'.format(groupname) - results = ldap_conn.search_s(BASE_GROUPS, ldap.SCOPE_SUBTREE, criteria, ['member'] ) - members_tmp = results[0][1]['member'] - for m in members_tmp: - members.append(m) -# print("m = {}".format(m)) #Debug - - return(members) + results = ldap_conn.search_s(secrets.BASE_GROUPS, ldap.SCOPE_SUBTREE, criteria, ['member']) + members_tmp = results[0][1] + members = members_tmp.get('member', []) + return [find_dn(dn.decode()) for dn in members] finally: ldap_conn.unbind() -def add_to_group(groupname,username): +def is_member(groupname, username): ''' - Add a user to a Group; required data is GroupName, Username + Checks to see if a user is a member of a group ''' - print("== Enter add_to_group ==") ldap_conn = init_ldap() try: - print(' --- Enter add_to_group with {0}, {1}---'.format(groupname,username)) + logger.info('Checking if ' + username + ' is in group: ' + groupname) ldap_conn.simple_bind_s(secrets.LDAP_USERNAME, secrets.LDAP_PASSWORD) - # get DN of the groupname - group_dn = find_group(groupname) - - #get DN of the username - user_dn = find_user(username) - - # -- TODO: Check to see if user is already a member, skip if not needed - - mod_acct = [(ldap.MOD_ADD, 'member', user_dn.encode())] - result = ldap_conn.modify_s(group_dn, mod_acct) + group_dn = find_group(groupname) + user_dn = find_user(username).encode() + memflag = False + criteria = '(&(objectClass=group)(sAMAccountName={}))'.format(groupname) + results = ldap_conn.search_s(secrets.BASE_GROUPS, ldap.SCOPE_SUBTREE, criteria, ['member'] ) + members_tmp = results[0][1] + members = members_tmp.get('member', []) + result = user_dn in members + logger.info(' Result: ' + str(result)) + return result + finally: + ldap_conn.unbind() + +def dump_users(): + ''' + Dump all AD users + ''' + ldap_conn = init_ldap() + try: + ldap_conn.simple_bind_s(secrets.LDAP_USERNAME, secrets.LDAP_PASSWORD) + criteria = '(&(objectClass=user)(objectGUID=*))' + attributes = ['cn', 'sAMAccountName', 'mail', 'displayName', 'givenName', 'name', 'sn', 'logonCount', 'objectGUID'] + results = ldap_conn.search_s(secrets.BASE_MEMBERS, ldap.SCOPE_SUBTREE, criteria, attributes) + results = convert(results) + + output = {} + for r in results: + tmp = r[1] + tmp['dn'] = r[0] + output[r[1]['sAMAccountName']] = tmp + + import json + return json.dumps(output, indent=4) finally: ldap_conn.unbind() +# =========================================================================== + + #guid = '\\b4\\51\\1adce6709c449bd21a812c423e82' + #guid = ''.join(['\\%s' % guid[i:i+2] for i in range(0, len(guid), 2)]) + #print(guid) + #criteria = '(&(objectClass=user)(objectGUID={}))'.format(guid) if __name__ == '__main__': + pass + #print(create_user('Elon', 'Tusk', 'elon.tusk', 'elont@example.com', 'protospace*&^g87g6')) #print(find_user('tanner.collin')) - #print(set_password('dsaftanner.collin', 'Supersecret@@')) - - # create a new group - create_group("testgroup") - print(find_group("testgroup") - - # List Group members - print("-- Members of {}".format("Laser Trainers")) - group_members = list_group("Laser Trainers") - for member in group_members: - print('{}'.format(member)) - - # add users to test group - add_to_group("testgroup","pat.spencer") - add_to_group("testgroup","Tanner.Collin") - # List Group members - print("-- Members of {}".format("testgroup")) - group_members = list_group("testgroup") - for member in group_members: - print('{}'.format(member)) + #print(set_password('tanner.collin', 'Supersecret@@')) + #print(find_dn('CN=Tanner Collin,OU=MembersOU,DC=ps,DC=protospace,DC=ca')) + #print("============================================================") + #print(create_group("newgroup", "new group")) + #print(" ============== ") + #print(list_group("Laser Users")) + #print(" ============== ") + #print(is_member('newgroup','tanner.collin')) + #print(" ============== ") + #print(add_to_group('newgroup','tanner.collin')) + #print(" ============== ") + #print(list_group("newgroup")) + #print(" ============== ") + #print(remove_from_group('newgroup','tanner.collin')) + #print(" ============== ") + print(list_group('Trotec Users')) + #print(dump_users()) + + #users = list_group('Laser Users') + #import json + #print(json.dumps(users, indent=4)) diff --git a/ldapserver/log.py b/ldapserver/log.py new file mode 100644 index 0000000..332246b --- /dev/null +++ b/ldapserver/log.py @@ -0,0 +1,59 @@ +import logging +import logging.config + +class IgnorePing(logging.Filter): + def filter(self, record): + return 'GET /ping' not in record.getMessage() + +LOG_DICT = { + 'version': 1, + 'formatters': { + 'default': { + 'format': '[%(asctime)s] [%(process)d] [%(levelname)7s] %(message)s', + }, + }, + 'filters': { + 'ignore_ping': { + '()': 'log.IgnorePing', + }, + }, + 'handlers': { + 'wsgi': { + 'class': 'logging.StreamHandler', + 'filters': ['ignore_ping'], + 'stream': 'ext://flask.logging.wsgi_errors_stream', + 'formatter': 'default' + }, + 'console': { + 'level': 'DEBUG', + 'filters': ['ignore_ping'], + 'class': 'logging.StreamHandler', + 'formatter': 'default' + }, + 'null': { + 'level': 'DEBUG', + 'filters': ['ignore_ping'], + 'class': 'logging.NullHandler', + 'formatter': 'default' + }, + }, + 'loggers': { + 'gunicorn': { + 'handlers': ['console'], + 'level': 'DEBUG', + 'propagate': False, + }, + }, + 'root': { + 'level': 'INFO', + 'handlers': ['wsgi'] + } +} + +logging.config.dictConfig(LOG_DICT) +logger = logging.getLogger(__name__) + +logger.info('Logging enabled.') + +from logging_tree import printout +printout() diff --git a/ldapserver/requirements.txt b/ldapserver/requirements.txt index bb11606..29aa6a7 100644 --- a/ldapserver/requirements.txt +++ b/ldapserver/requirements.txt @@ -3,6 +3,7 @@ Flask==1.1.1 gunicorn==20.0.4 itsdangerous==1.1.0 Jinja2==2.11.1 +logging-tree==1.8.1 MarkupSafe==1.1.1 pyasn1==0.4.8 pyasn1-modules==0.2.8 diff --git a/ldapserver/secrets.py.example b/ldapserver/secrets.py.example index d73a4e4..3078416 100644 --- a/ldapserver/secrets.py.example +++ b/ldapserver/secrets.py.example @@ -8,3 +8,9 @@ AUTH_TOKEN = '' LDAP_USERNAME = '' LDAP_PASSWORD = '' + +LDAP_CERTFILE = '' +LDAP_URL = '' + +BASE_MEMBERS = '' +BASE_GROUPS = '' diff --git a/ldapserver/server.py b/ldapserver/server.py index ee696c0..881395e 100644 --- a/ldapserver/server.py +++ b/ldapserver/server.py @@ -1,3 +1,5 @@ +from log import logger + from flask import Flask, abort, request app = Flask(__name__) @@ -13,8 +15,14 @@ def check_auth(): @app.route('/') def index(): + logger.info('Index page requested') + return 'SEE YOU SPACE SAMURAI...' +@app.route('/ping') +def ping(): + return 'pong' + @app.route('/find-user', methods=['POST']) def find_user(): check_auth() @@ -46,5 +54,25 @@ def set_password(): ldap_functions.set_password(username, password) return '' +@app.route('/add-to-group', methods=['POST']) +def add_to_group(): + check_auth() + + groupname = request.form['group'] + username = request.form.get('username', None) or request.form.get('email', None) + + ldap_functions.add_to_group(groupname, username) + return '' + +@app.route('/remove-from-group', methods=['POST']) +def remove_from_group(): + check_auth() + + groupname = request.form['group'] + username = request.form.get('username', None) or request.form.get('email', None) + + ldap_functions.remove_from_group(groupname, username) + return '' + if __name__ == '__main__': app.run(debug=True, host='0.0.0.0') diff --git a/webclient/package.json b/webclient/package.json index b7c7efa..dc5a718 100644 --- a/webclient/package.json +++ b/webclient/package.json @@ -21,7 +21,8 @@ "react-to-print": "~2.5.1", "recharts": "~1.8.5", "semantic-ui-react": "~0.88.2", - "three": "^0.119.1" + "three": "^0.119.1", + "serialize-javascript": "^3.1.0" }, "scripts": { "start": "react-scripts start", diff --git a/webclient/public/wikilogo.png b/webclient/public/wikilogo.png new file mode 100644 index 0000000..228f352 Binary files /dev/null and b/webclient/public/wikilogo.png differ diff --git a/webclient/src/AdminMembers.js b/webclient/src/AdminMembers.js index fe3ed1e..79411a1 100644 --- a/webclient/src/AdminMembers.js +++ b/webclient/src/AdminMembers.js @@ -123,7 +123,7 @@ export function AdminMemberCards(props) { const [error, setError] = useState(false); const [loading, setLoading] = useState(false); const [success, setSuccess] = useState(false); - const [viewCard, setViewCard] = useState(false); + const [cardPhoto, setCardPhoto] = useState(false); const { id } = useParams(); useEffect(() => { @@ -155,6 +155,18 @@ export function AdminMemberCards(props) { }); }; + const getCardPhoto = (e) => { + e.preventDefault(); + requester('/members/' + id + '/card_photo/', 'GET', token) + .then(res => res.blob()) + .then(res => { + setCardPhoto(URL.createObjectURL(res)); + }) + .catch(err => { + console.log(err); + }); + }; + const makeProps = (name) => ({ name: name, onChange: handleChange, @@ -176,17 +188,17 @@ export function AdminMemberCards(props) {
Add a Card
- {result.member.card_photo ? + {result.member.photo_large ?

- +

:

No card image, member photo missing!

} - {viewCard && <> + {cardPhoto && <>

- +

How to Print a Card
@@ -221,7 +233,15 @@ export function AdminMemberCards(props) { /> - + + + Submit {success &&
Success!
} @@ -498,6 +518,12 @@ export function AdminMemberInfo(props) { Emergency Contact Phone: {member.emergency_contact_phone || 'None'} + + + On Spaceport: + {member.user ? 'Yes' : 'No'} + + Public Bio: @@ -541,6 +567,7 @@ export function AdminCert(props) { const handleCert = (e) => { e.preventDefault(); + if (loading) return; setLoading(true); let data = Object(); data[field] = moment.utc().tz('America/Edmonton').format('YYYY-MM-DD'); @@ -555,6 +582,7 @@ export function AdminCert(props) { const handleUncert = (e) => { e.preventDefault(); + if (loading) return; setLoading(true); let data = Object(); data[field] = null; @@ -646,6 +674,18 @@ export function AdminMemberCertifications(props) { Tormach: CAM and Tormach Intro + + Rabbit Laser + {member.rabbit_cert_date ? 'Yes, ' + member.rabbit_cert_date : 'No'} + Laser: Cutting and Engraving + + + + Trotec Laser + {member.trotec_cert_date ? 'Yes, ' + member.trotec_cert_date : 'No'} + Laser: Trotec Course + + diff --git a/webclient/src/App.js b/webclient/src/App.js index d1d036a..c3d52e9 100644 --- a/webclient/src/App.js +++ b/webclient/src/App.js @@ -2,6 +2,7 @@ import React, { useState, useEffect, useReducer, useContext } from 'react'; import { BrowserRouter as Router, Switch, Route, Link, useParams, useHistory } from 'react-router-dom'; import './semantic-ui/semantic.min.css'; import './light.css'; +import './dark.css'; import { Container, Divider, Dropdown, Form, Grid, Header, Icon, Image, Menu, Message, Segment, Table } from 'semantic-ui-react'; import Darkmode from 'darkmode-js'; import { isAdmin, requester } from './utils.js'; @@ -19,6 +20,7 @@ import { Courses, CourseDetail } from './Courses.js'; import { Classes, ClassDetail } from './Classes.js'; import { Members, MemberDetail } from './Members.js'; import { Charts } from './Charts.js'; +import { Auth } from './Auth.js'; import { PasswordReset, ConfirmReset } from './PasswordReset.js'; import { NotFound, PleaseLogin } from './Misc.js'; import { Footer } from './Footer.js'; @@ -107,6 +109,10 @@ function App() { + + {window.location.hostname !== 'my.protospace.ca' && +

~~~~~ Development site ~~~~~

+ } @@ -216,6 +222,10 @@ function App() { + + + + {user && user.member.set_details ? diff --git a/webclient/src/Auth.js b/webclient/src/Auth.js new file mode 100644 index 0000000..dabeecc --- /dev/null +++ b/webclient/src/Auth.js @@ -0,0 +1,132 @@ +import React, { useState, useEffect, useReducer } from 'react'; +import { BrowserRouter as Router, Switch, Route, Link, useParams, useLocation } from 'react-router-dom'; +import moment from 'moment-timezone'; +import './light.css'; +import { Container, Divider, Dropdown, Form, Grid, Header, Icon, Image, Menu, Message, Popup, Segment, Table } from 'semantic-ui-react'; +import { statusColor, BasicTable, staticUrl, requester, isAdmin } from './utils.js'; + +export function AuthForm(props) { + const { user } = props; + const username = user ? user.username : ''; + const [input, setInput] = useState({ username: username }); + const [error, setError] = useState({}); + const [success, setSuccess] = useState(false); + const [loading, setLoading] = useState(false); + + const handleValues = (e, v) => setInput({ ...input, [v.name]: v.value }); + const handleChange = (e) => handleValues(e, e.currentTarget); + + const handleSubmit = (e) => { + if (input.username.includes('@')) { + setError({ username: 'Username, not email.' }); + } else { + if (loading) return; + setLoading(true); + const data = { ...input, username: input.username.toLowerCase() }; + requester('/spaceport-auth/login/', 'POST', '', data) + .then(res => { + setSuccess(true); + setError({}); + }) + .catch(err => { + setLoading(false); + console.log(err); + setError(err.data); + }); + } + }; + + return ( + success ? + props.children + : + +
Log In to Spaceport
+ + {user ? + <> + + : + <> + + } + + + Authorize + + + + Forgot your password? +

Click here to reset it.

+
+ + ); +}; + +export function AuthWiki(props) { + const { user } = props; + + return ( + +
+ + Protospace Wiki +
+ +

would like to request Spaceport authentication.

+ +

URL: wiki.protospace.ca

+ + +
Success!
+

You can now log into the wiki:

+

Protospace Wiki

+
+
+ ); +} + +export function Auth(props) { + const { user } = props; + + return ( + +
Spaceport Auth
+ +

Use this page to link different applications to your Spaceport account.

+ + + + +
+ ); +} diff --git a/webclient/src/Charts.js b/webclient/src/Charts.js index 6b39705..8702fd7 100644 --- a/webclient/src/Charts.js +++ b/webclient/src/Charts.js @@ -13,6 +13,7 @@ export function Charts(props) { const [memberCount, setMemberCount] = useState(memberCountCache); const [signupCount, setSignupCount] = useState(signupCountCache); const [spaceActivity, setSpaceActivity] = useState(spaceActivityCache); + const [fullActivity, setFullActivity] = useState(false); useEffect(() => { requester('/charts/membercount/', 'GET') @@ -47,6 +48,33 @@ export function Charts(props) {
Charts
+
Summary
+ + {memberCount && signupCount && + <> +

+ The total member count is {memberCount.slice().reverse()[0].member_count} members, + compared to {memberCount.slice().reverse()[30].member_count} members 30 days ago. +

+

+ The green member count is {memberCount.slice().reverse()[0].green_count} members, + compared to {memberCount.slice().reverse()[30].green_count} members 30 days ago. +

+

+ The older than six months member count is {memberCount.slice().reverse()[0].six_month_plus_count} members, + compared to {memberCount.slice().reverse()[30].six_month_plus_count} members 30 days ago. +

+

+ The vetted member count is {memberCount.slice().reverse()[0].vetted_count} members, + compared to {memberCount.slice().reverse()[30].vetted_count} members 30 days ago. +

+

+ There were {signupCount.slice().reverse()[0].signup_count} signups so far this month, + and {signupCount.slice().reverse()[1].signup_count} signups last month. +

+ + } +
Member Counts

Daily since March 2nd, 2020.

@@ -87,18 +115,99 @@ export function Charts(props) { }

-

The Member Count is the amount of Prepaid, Current, Due, and Overdue members on Spaceport.

+

Member Count: number of active paying members on Spaceport.

-

The Green Count is the amount of Prepaid and Current members.

+

Green Count: number of Prepaid and Current members.

+ +

+ {memberCount && + + + + + + + + + + + + + } +

+ +

Member Count: same as above.

+ +

Six Months+: number of active memberships older than six months.

+ +

+ {memberCount && + + + + + + + + + + + + + } +

+ +

Member Count: same as above.

+ +

Vetted Count: number of active vetted members.

Space Activity
-

Daily since March 7th, 2020, updates hourly.

+ {fullActivity ? +

Daily since March 7th, 2020, updates hourly.

+ : +

+ Last four weeks, updates hourly. + {' '} +

+ }

{spaceActivity && - + @@ -111,14 +220,14 @@ export function Charts(props) { name='Card Scans' fill='#8884d8' maxBarSize={20} - animationDuration={1000} + isAnimationActive={false} /> }

-

Cards Scans is the number of individual members who have scanned to enter the space.

+

Cards Scans: number of individual members who have scanned to enter the space.

Signup Count
@@ -146,14 +255,14 @@ export function Charts(props) { type='monotone' dataKey='vetted_count' fill='#80b3d3' - name='Vetted Count' + name='Later Vetted Count' maxBarSize={20} animationDuration={1200} /> -

The Signup Count is the number of brand new account registrations that month.

+

Signup Count: number of brand new account registrations that month.

-

The Vetted Count is the number of those signups who eventually got vetted (at a later date).

+

Later Vetted Count: number of those signups who eventually got vetted (at a later date).

-

The Retain Count is the number of those signups who are still a member currently.

+

Retained Count: number of those signups who are still a member currently.

); diff --git a/webclient/src/Classes.js b/webclient/src/Classes.js index b839b58..dc4a802 100644 --- a/webclient/src/Classes.js +++ b/webclient/src/Classes.js @@ -72,13 +72,19 @@ export function Classes(props) {
Class List
Upcoming
+ +

Ordered by nearest date.

+ {classes ? - x.datetime > now)} /> + x.datetime > now).sort((a, b) => a.datetime > b.datetime ? 1 : -1)} /> :

Loading...

}
Recent
+ +

Ordered by nearest date.

+ {classes ? x.datetime < now)} /> : @@ -92,6 +98,7 @@ export function ClassDetail(props) { const [clazz, setClass] = useState(false); const [refreshCount, refreshClass] = useReducer(x => x + 1, 0); const [error, setError] = useState(false); + const [loading, setLoading] = useState(false); const { token, user, refreshUser } = props; const { id } = useParams(); const userTraining = clazz && clazz.students.find(x => x.user == user.id); @@ -108,6 +115,8 @@ export function ClassDetail(props) { }, [refreshCount]); const handleSignup = () => { + if (loading) return; + setLoading(true); const data = { attendance_status: 'Waiting for payment', session: id }; requester('/training/', 'POST', token, data) .then(res => { @@ -120,6 +129,8 @@ export function ClassDetail(props) { }; const handleToggle = (newStatus) => { + if (loading) return; + setLoading(true); const data = { attendance_status: newStatus, session: id }; requester('/training/'+userTraining.id+'/', 'PUT', token, data) .then(res => { @@ -132,6 +143,10 @@ export function ClassDetail(props) { }); }; + useEffect(() => { + setLoading(false); + }, [userTraining]); + // TODO: calculate yesterday and lock signups return ( @@ -198,11 +213,11 @@ export function ClassDetail(props) {

Status: {userTraining.attendance_status}

{userTraining.attendance_status === 'Withdrawn' ? - : - } @@ -226,7 +241,7 @@ export function ClassDetail(props) { ((clazz.max_students && clazz.student_count >= clazz.max_students) ?

The class is full.

: - ) diff --git a/webclient/src/Home.js b/webclient/src/Home.js index 876212c..429ed37 100644 --- a/webclient/src/Home.js +++ b/webclient/src/Home.js @@ -1,5 +1,5 @@ import React, { useState, useEffect, useReducer } from 'react'; -import { BrowserRouter as Router, Switch, Route, Link, useParams } from 'react-router-dom'; +import { BrowserRouter as Router, Switch, Route, Link, useParams, useLocation } from 'react-router-dom'; import moment from 'moment-timezone'; import './light.css'; import { Container, Divider, Dropdown, Form, Grid, Header, Icon, Image, Menu, Message, Popup, Segment, Table } from 'semantic-ui-react'; @@ -128,12 +128,15 @@ function MemberInfo(props) { }; export function Home(props) { - const { user } = props; + const { user, token } = props; const [stats, setStats] = useState(JSON.parse(localStorage.getItem('stats', 'false'))); const [refreshCount, refreshStats] = useReducer(x => x + 1, 0); + const location = useLocation(); + + const bypass_code = location.hash.replace('#', ''); useEffect(() => { - requester('/stats/', 'GET') + requester('/stats/', 'GET', token) .then(res => { setStats(res); localStorage.setItem('stats', JSON.stringify(res)); @@ -142,17 +145,21 @@ export function Home(props) { console.log(err); setStats(false); }); - }, [refreshCount]); + }, [refreshCount, token]); const getStat = (x) => stats && stats[x] ? stats[x] : '?'; const getZeroStat = (x) => stats && stats[x] ? stats[x] : '0'; const getDateStat = (x) => stats && stats[x] ? moment.utc(stats[x]).tz('America/Edmonton').format('ll') : '?'; const mcPlayers = stats && stats['minecraft_players'] ? stats['minecraft_players'] : []; + const mumbleUsers = stats && stats['mumble_users'] ? stats['mumble_users'] : []; - const getTrackStat = (x) => stats && stats.track && stats.track[x] ? moment().unix() - stats.track[x] > 60 ? 'Free' : 'In Use' : '?'; - const getTrackLast = (x) => stats && stats.track && stats.track[x] ? moment.unix(stats.track[x]).tz('America/Edmonton').format('llll') : 'Unknown'; - const getTrackAgo = (x) => stats && stats.track && stats.track[x] ? moment.unix(stats.track[x]).tz('America/Edmonton').fromNow() : ''; + const getTrackStat = (x) => stats && stats.track && stats.track[x] ? moment().unix() - stats.track[x]['time'] > 60 ? 'Free' : 'In Use' : '?'; + const getTrackLast = (x) => stats && stats.track && stats.track[x] ? moment.unix(stats.track[x]['time']).tz('America/Edmonton').format('llll') : 'Unknown'; + const getTrackAgo = (x) => stats && stats.track && stats.track[x] ? moment.unix(stats.track[x]['time']).tz('America/Edmonton').fromNow() : ''; + const getTrackName = (x) => stats && stats.track && stats.track[x] && stats.track[x]['username'] ? stats.track[x]['username'] : 'Unknown'; + + const alarmStat = () => stats && stats.alarm && moment().unix() - stats.alarm['time'] < 300 ? stats.alarm['data'] > 200 ? 'Armed' : 'Disarmed' : 'Unknown'; return ( @@ -172,9 +179,18 @@ export function Home(props) { :
- + {bypass_code ? + + Outside Registration +

This page allows you to sign up from outside of Protospace.

+
+ : + <> + - Or + Or + + }
@@ -201,11 +217,10 @@ export function Home(props) {

Next monthly clean: {getDateStat('next_clean')}

Member count: {getStat('member_count')} [more]

Green members: {getStat('green_count')}

-

Old members: {getStat('paused_count')}

Card scans today: {getZeroStat('card_scans')}

- Minecraft players: {mcPlayers.length} 5 && '🔥'}

Server IP:
@@ -217,6 +232,22 @@ export function Home(props) {

} trigger={[more]} /> + {' '}[map] +

+ +

+ Mumble users: {mumbleUsers.length} +

+ Server IP:
+ mumble.protospace.ca +

+

+ Users:
+ {mumbleUsers.length ? mumbleUsers.map(x => {x}
) : 'None'} +

+ + } trigger={[more]} />

@@ -225,7 +256,8 @@ export function Home(props) {

Last use:
{getTrackLast('TROTECS300')}
- {getTrackAgo('TROTECS300')} + {getTrackAgo('TROTECS300')}
+ by {getTrackName('TROTECS300')}

} trigger={[more]} /> @@ -237,11 +269,14 @@ export function Home(props) {

Last use:
{getTrackLast('FRICKIN-LASER')}
- {getTrackAgo('FRICKIN-LASER')} + {getTrackAgo('FRICKIN-LASER')}
+ by {getTrackName('FRICKIN-LASER')}

} trigger={[more]} />

+ + {user && user.member.vetted_date &&

Alarm status: {alarmStat()}

} diff --git a/webclient/src/InstructorClasses.js b/webclient/src/InstructorClasses.js index fa23ff8..b602850 100644 --- a/webclient/src/InstructorClasses.js +++ b/webclient/src/InstructorClasses.js @@ -68,8 +68,12 @@ class AttendanceSheet extends React.Component { function AttendanceRow(props) { const { student, token, refreshClass } = props; const [error, setError] = useState(false); + const [loading, setLoading] = useState(false); const handleMark = (newStatus) => { + if (loading) return; + if (student.attendance_status == newStatus) return; + setLoading(newStatus); const data = { ...student, attendance_status: newStatus }; requester('/training/'+student.id+'/', 'PATCH', token, data) .then(res => { @@ -86,11 +90,19 @@ function AttendanceRow(props) { onClick: () => handleMark(name), toggle: true, active: student.attendance_status === name, + loading: loading === name, }); + useEffect(() => { + setLoading(false); + }, [student.attendance_status]); + return (
-

{student.student_name}:

+

+ {student.student_name} + {student.attendance_status === 'Waiting for payment' && ' (Waiting for payment)'}: +

: - } @@ -321,7 +335,7 @@ export function InstructorClassDetail(props) { export function InstructorClassList(props) { const { course, setCourse, token } = props; const [open, setOpen] = useState(false); - const [input, setInput] = useState({}); + const [input, setInput] = useState({ max_students: null }); const [error, setError] = useState(false); const [loading, setLoading] = useState(false); const [success, setSuccess] = useState(false); diff --git a/webclient/src/LoginSignup.js b/webclient/src/LoginSignup.js index 36e2c70..ec8634d 100644 --- a/webclient/src/LoginSignup.js +++ b/webclient/src/LoginSignup.js @@ -110,7 +110,7 @@ export function SignupForm(props) { return (
-
Sign Up from Protospace
+
Sign Up to Spaceport
Status: {x.member.status || 'Unknown'} - Joined: {x.member.current_start_date || 'Unknown'} + Joined: {x.member.application_date || 'Unknown'} ) @@ -154,7 +154,7 @@ export function MemberDetail(props) {
{member.preferred_name} {member.last_name}
- +

@@ -174,7 +174,7 @@ export function MemberDetail(props) { Joined: - {member.current_start_date || 'Unknown'} + {member.application_date || 'Unknown'} Public Bio: @@ -189,7 +189,11 @@ export function MemberDetail(props) { }
- + + {isInstructor(user) && !isAdmin(user) && + + } + {isAdmin(user) && } diff --git a/webclient/src/PasswordReset.js b/webclient/src/PasswordReset.js index e338a95..5f8e76a 100644 --- a/webclient/src/PasswordReset.js +++ b/webclient/src/PasswordReset.js @@ -37,7 +37,7 @@ function ResetForm() { }); return ( - + + + Submit - {success &&
Success!
} + {success &&
Success! Be sure to check your spam folder.
} ); }; diff --git a/webclient/src/Paymaster.js b/webclient/src/Paymaster.js index 7d9cfc4..1e344cd 100644 --- a/webclient/src/Paymaster.js +++ b/webclient/src/Paymaster.js @@ -13,6 +13,8 @@ export function Paymaster(props) { const [locker, setLocker] = useState('5.00'); const [donate, setDonate] = useState('20.00'); + const monthly_fees = user.member.monthly_fees || 55; + return (
Paymaster
@@ -62,27 +64,27 @@ export function Paymaster(props) {
Member Dues
-

Pay ${user.member.monthly_fees}.00 once:

+

Pay ${monthly_fees}.00 once:

-

Subscribe ${user.member.monthly_fees}.00 / month:

+

Subscribe ${monthly_fees}.00 / month:

-

Pay ${user.member.monthly_fees * 11}.00 for a year:

+

Pay ${monthly_fees * 11}.00 for a year:

diff --git a/webclient/src/Training.js b/webclient/src/Training.js index 64d96a4..b099c02 100644 --- a/webclient/src/Training.js +++ b/webclient/src/Training.js @@ -57,6 +57,16 @@ export function CertList(props) { {member.cnc_cert_date ? 'Yes, ' + member.cnc_cert_date : 'No'} Tormach: CAM and Tormach Intro + + Rabbit Laser + {member.rabbit_cert_date ? 'Yes, ' + member.rabbit_cert_date : 'No'} + Laser: Cutting and Engraving + + + Trotec Laser + {member.trotec_cert_date ? 'Yes, ' + member.trotec_cert_date : 'No'} + Laser: Trotec Course + ); diff --git a/webclient/src/Transactions.js b/webclient/src/Transactions.js index 8b6849b..eb3e4a0 100644 --- a/webclient/src/Transactions.js +++ b/webclient/src/Transactions.js @@ -23,14 +23,14 @@ export function TransactionEditor(props) { }); const accountOptions = [ - { key: '0', text: 'Cash (CAD Lock Box)', value: 'Cash' }, + { key: '0', text: 'Cash (Lock Box)', value: 'Cash' }, { key: '1', text: 'Interac (Email) Transfer (TD)', value: 'Interac' }, - { key: '2', text: 'Square (Credit)', value: 'Square Pmt' }, - { key: '3', text: 'Dream Payments (Debit/Credit)', value: 'Dream Pmt' }, - { key: '4', text: 'Deposit to TD (Not Interac)', value: 'TD Chequing' }, - { key: '5', text: 'PayPal', value: 'PayPal' }, - { key: '6', text: 'Member Balance / Protocash', value: 'Member' }, - { key: '7', text: 'Supense (Clearing) Acct / Membership Adjustment', value: 'Clearing' }, + { key: '2', text: 'Square (Credit Card)', value: 'Square Pmt' }, + //{ key: '3', text: 'Dream Payments (Debit/Credit)', value: 'Dream Pmt' }, + { key: '4', text: 'Cheque / Deposit to TD', value: 'TD Chequing' }, + //{ key: '5', text: 'Member Balance / Protocash', value: 'Member' }, + { key: '6', text: 'Membership Adjustment / Clearing', value: 'Clearing' }, + { key: '7', text: 'PayPal', value: 'PayPal' }, ]; const sourceOptions = [ @@ -53,9 +53,9 @@ export function TransactionEditor(props) { { key: '1', text: 'Payment On Account (ie. Course Fee)', value: 'OnAcct' }, { key: '2', text: 'Snack / Pop / Coffee', value: 'Snacks' }, { key: '3', text: 'Donations', value: 'Donation' }, - { key: '4', text: 'Consumables (Specify which in memo)', value: 'Consumables' }, + { key: '4', text: 'Consumables (Explain in memo)', value: 'Consumables' }, { key: '5', text: 'Purchase of Locker / Goods / Merch / Stock', value: 'Purchases' }, - { key: '6', text: 'Auction, Garage Sale, Nearly Free Shelf', value: 'Garage Sale' }, + //{ key: '6', text: 'Auction, Garage Sale, Nearly Free Shelf', value: 'Garage Sale' }, { key: '7', text: 'Reimbursement (Enter a negative value)', value: 'Reimburse' }, { key: '8', text: 'Other (Explain in memo)', value: 'Other' }, ]; @@ -94,14 +94,14 @@ export function TransactionEditor(props) { /> - + {/* - + */} @@ -349,10 +349,10 @@ class TransactionTable extends React.Component { Account: {transaction.account_type} - + {/* Payment Method: {transaction.payment_method} - + */} Info Source: {transaction.info_source} diff --git a/webclient/src/dark.css b/webclient/src/dark.css new file mode 100644 index 0000000..c2a53cf --- /dev/null +++ b/webclient/src/dark.css @@ -0,0 +1,57 @@ +.darkmode-layer, .darkmode-toggle { + z-index: 500; +} + +.darkmode--activated .ui.image { + mix-blend-mode: difference; + filter: brightness(75%); +} + +.darkmode--activated i.green.circle.icon { + mix-blend-mode: difference; + color: #21ba4582 !important; + +} + +.darkmode--activated i.yellow.circle.icon { + mix-blend-mode: difference; + color: #fbbd0882 !important; +} + +.darkmode--activated i.red.circle.icon { + mix-blend-mode: difference; + color: #db282882 !important; +} + +.darkmode--activated .footer { + mix-blend-mode: difference; +} + +.darkmode--activated .ql-toolbar.ql-snow, +.darkmode--activated .ql-container.ql-snow, +.darkmode--activated .ui.segment, +.darkmode--activated .ui.form .field input, +.darkmode--activated .ui.form .field .selection.dropdown, +.darkmode--activated .ui.form .field .ui.checkbox label::before, +.darkmode--activated .ui.form .field textarea { + border: 1px solid rgba(34,36,38,.50) !important; +} + +.darkmode--activated .ui.basic.table tbody tr { + border-bottom: 1px solid rgba(34,36,38,.50) !important; +} + +.darkmode--activated .ui.button { + background: #c9c9c9 !important; +} + +.darkmode--activated .ui.red.button { + mix-blend-mode: difference; + background: #db282882 !important; +} + +.darkmode--activated .ui.green.button, +.darkmode--activated .ui.button.toggle.active { + mix-blend-mode: difference; + background: #21ba4582 !important; +} diff --git a/webclient/src/utils.js b/webclient/src/utils.js index 6140508..5585257 100644 --- a/webclient/src/utils.js +++ b/webclient/src/utils.js @@ -70,7 +70,17 @@ export const requester = (route, method, token, data) => { if (!response.ok) { throw customError(response); } - return method === 'DELETE' ? {} : response.json(); + + if (method === 'DELETE') { + return {}; + } + + const contentType = response.headers.get('content-type'); + if (contentType && contentType.indexOf('application/json') !== -1) { + return response.json(); + } else { + return response; + } }) .catch(error => { const code = error.data ? error.data.status : null; diff --git a/webclient/yarn.lock b/webclient/yarn.lock index c83e352..5aff84c 100644 --- a/webclient/yarn.lock +++ b/webclient/yarn.lock @@ -3664,7 +3664,7 @@ debug@2.6.9, debug@^2.2.0, debug@^2.3.3, debug@^2.6.0, debug@^2.6.9: dependencies: ms "2.0.0" -debug@^3.0.0, debug@^3.1.1, debug@^3.2.5: +debug@^3.1.1, debug@^3.2.5: version "3.2.6" resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.6.tgz#e83d17de16d8a7efb7717edbe5fb10135eee629b" integrity sha512-mel+jf7nrtEl5Pn1Qx46zARXKDpBbvzezse7p7LqINmdoIk8PYP5SySaxEmYv6TZ0JyEKA1hsCId6DIhgITtWQ== @@ -4376,9 +4376,9 @@ eventemitter3@^2.0.3: integrity sha1-teEHm1n7XhuidxwKmTvgYKWMmbo= eventemitter3@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-4.0.0.tgz#d65176163887ee59f386d64c82610b696a4a74eb" - integrity sha512-qerSRB0p+UDEssxTtm6EDKcE7W4OaoisfIMl4CngyEhjpYglocpNg6UEqCvemdGhosAsg4sO2dXJOdyBifPGCg== + version "4.0.7" + resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-4.0.7.tgz#2de9b68f6528d5644ef5c59526a1b4a07306169f" + integrity sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw== events@^3.0.0: version "3.1.0" @@ -4767,11 +4767,9 @@ flush-write-stream@^1.0.0: readable-stream "^2.3.6" follow-redirects@^1.0.0: - version "1.10.0" - resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.10.0.tgz#01f5263aee921c6a54fb91667f08f4155ce169eb" - integrity sha512-4eyLK6s6lH32nOvLLwlIOnr9zrL8Sm+OvW4pVTJNoXeGzYIkHVf+pADQi+OJ0E67hiuSLezPVPyBcIZO50TmmQ== - dependencies: - debug "^3.0.0" + version "1.13.0" + resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.13.0.tgz#b42e8d93a2a7eea5ed88633676d6597bc8e384db" + integrity sha512-aq6gF1BEKje4a9i9+5jimNFIpq4Q1WiwBToeRK5NvZBd/TRsmW8BsJfOEGkr76TbOyPVD3OVDN910EcUNtRYEA== for-in@^0.1.3: version "0.1.8" @@ -5335,9 +5333,9 @@ http-proxy-middleware@0.19.1: micromatch "^3.1.10" http-proxy@^1.17.0: - version "1.18.0" - resolved "https://registry.yarnpkg.com/http-proxy/-/http-proxy-1.18.0.tgz#dbe55f63e75a347db7f3d99974f2692a314a6a3a" - integrity sha512-84I2iJM/n1d4Hdgc6y2+qY5mDaz2PUVjlg9znE9byl+q0uC3DeByqBGReQu5tpLK0TAqTIXScRUV+dg7+bUPpQ== + version "1.18.1" + resolved "https://registry.yarnpkg.com/http-proxy/-/http-proxy-1.18.1.tgz#401541f0534884bbf95260334e72f88ee3976549" + integrity sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ== dependencies: eventemitter3 "^4.0.0" follow-redirects "^1.0.0" @@ -8842,7 +8840,7 @@ raf@^3.4.0, raf@^3.4.1: dependencies: performance-now "^2.1.0" -randombytes@^2.0.0, randombytes@^2.0.1, randombytes@^2.0.5: +randombytes@^2.0.0, randombytes@^2.0.1, randombytes@^2.0.5, randombytes@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/randombytes/-/randombytes-2.1.0.tgz#df6f84372f0270dc65cdf6291349ab7a473d4f2a" integrity sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ== @@ -9754,6 +9752,13 @@ serialize-javascript@^2.1.2: resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-2.1.2.tgz#ecec53b0e0317bdc95ef76ab7074b7384785fa61" integrity sha512-rs9OggEUF0V4jUSecXazOYsLfu7OGK2qIn3c7IPBiffz32XniEp/TX9Xmc9LQfK2nQ2QKHvZ2oygKUGU0lG4jQ== +serialize-javascript@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-3.1.0.tgz#8bf3a9170712664ef2561b44b691eafe399214ea" + integrity sha512-JIJT1DGiWmIKhzRsG91aS6Ze4sFUrYbltlkg2onR5OrnNM02Kl/hnY/T4FN2omvyeBbQmMJv+K4cPOpGzOTFBg== + dependencies: + randombytes "^2.1.0" + serve-index@^1.9.1: version "1.9.1" resolved "https://registry.yarnpkg.com/serve-index/-/serve-index-1.9.1.tgz#d3768d69b1e7d82e5ce050fff5b453bea12a9239"