diff --git a/admin.py b/admin.py index a06e53e..e01ee22 100644 --- a/admin.py +++ b/admin.py @@ -5,6 +5,7 @@ from django.contrib.auth.admin import UserAdmin as BaseUserAdmin from django.contrib.auth.models import User from django.utils.translation import ugettext_lazy as _ from minecraft_manager.models import Application, Warning, Ticket, Player, IP, UserSettings, Alert, Note +from minecraft_manager.api.admin import register as api_register class PlayerInline(admin.StackedInline): @@ -100,6 +101,7 @@ try: admin.site.register(IP, IPAdmin) admin.site.register(Alert) admin.site.register(Note) + api_register() except admin.sites.AlreadyRegistered: pass diff --git a/api/admin.py b/api/admin.py new file mode 100644 index 0000000..4a89974 --- /dev/null +++ b/api/admin.py @@ -0,0 +1,45 @@ +from django.contrib import admin +from django.utils.translation import ugettext_lazy as _ +from minecraft_manager.api.models import Token + + +class TokenActiveFilter(admin.SimpleListFilter): + title = _('Active') + parameter_name = 'active' + + def lookups(self, request, model_admin): + return ( + ('0', _('Active')), + ('1', _('Inactive')), + ) + + def queryset(self, request, queryset): + if self.value() == '0': + return queryset.filter(active=True) + if self.value() == '1': + return queryset.filter(active=False) + + +class TokenAdmin(admin.ModelAdmin): + list_filter = (TokenActiveFilter,) + list_display = ('display', 'active', 'web_get_permission', 'web_post_permission', 'plugin_get_permission', + 'plugin_post_permission', 'form_get_permission', 'form_post_permission', 'model_get_permission', + 'model_post_permission', 'stats_get_permission', 'stats_post_permission') + fieldsets = ( + (None, { + 'fields': ('key', 'active', 'description') + }), + ('Permissions', { + 'fields': ('web_get_permission', 'web_post_permission', 'plugin_get_permission', 'plugin_post_permission', + 'form_get_permission', 'form_post_permission', 'model_get_permission', 'model_post_permission', + 'stats_get_permission', 'stats_post_permission') + }) + ) + + +def register(): + try: + admin.site.register(Token, TokenAdmin) + except admin.sites.AlreadyRegistered: + pass + diff --git a/api/api.py b/api/api.py index 8127661..eadc7dc 100644 --- a/api/api.py +++ b/api/api.py @@ -1,4 +1,4 @@ -import socket, requests, logging, os, datetime, pytz, mcstatus, discord +import socket, requests, logging, os, datetime, pytz, mcstatus, random, string from minecraft_manager.models import Alert from django.contrib.auth.models import User from django.conf import settings @@ -18,6 +18,7 @@ PLUGIN_ACCEPT = 'accept' PLUGIN_DENY = 'deny' PLUGIN_GLOBAL_CHAT = 'global' PLUGIN_STAFF_CHAT = 'staff' +PLUGIN_DEMOTE = 'demote' def plugin(key, command): @@ -139,4 +140,8 @@ def get_query(): 'players': sorted(query.players.names)} except: return {'max': 0, 'online': 0, - 'players': []} \ No newline at end of file + 'players': []} + + +def generate_password(size=20): + return "".join([random.choice(string.ascii_letters + string.digits) for idx in range(0, size)]) \ No newline at end of file diff --git a/api/bot.py b/api/bot.py index 2a9e2f2..f242ad5 100644 --- a/api/bot.py +++ b/api/bot.py @@ -1,6 +1,7 @@ import discord, logging, re, sys, traceback, asyncio, datetime, os, time from minecraft_manager.models import Application, Player from minecraft_manager.api import api +from django.contrib.auth.models import User from django.conf import settings from django.db import close_old_connections from threading import Thread @@ -67,7 +68,8 @@ class Discord(discord.Client): embed.add_field(name="{}[app ]search ".format(self.prefix), value="Search for applications by partial or exact username.") embed.add_field(name="{}[app ]info ".format(self.prefix), value="Get detailed information about a specific application.") embed.add_field(name="{}[app ]accept|deny ".format(self.prefix), value="Take action on an application.") - embed.add_field(name="{}compare".format(self.prefix), value="Compare Discord users to the Whitelist") + embed.add_field(name="{}demote ".format(self.prefix), value="Demote a player to the role given to accepted applications.") + embed.add_field(name="{}compare".format(self.prefix), value="Compare Discord users to the Whitelist.") yield from self.discord_message(message.channel, embed) # APP COMMANDS WITH APP ID match = re.match("[{0}](?:app )?(i|info|a|accept|d|deny) (\d+)$".format(self.prefix), message.content) @@ -103,7 +105,7 @@ class Discord(discord.Client): match = re.match("[{0}](?:app )?(?:search|info) (\S+)?$".format(self.prefix), message.content) if match: search = match.group(1) - yield from self.discord_message(message.channel,"Searching for applications whose username contains '{0}'".format(search)) + yield from self.discord_message(message.channel, "Searching for applications whose username contains '{0}'".format(search)) applications = Application.objects.filter(username__icontains=search)[:10] count = Application.objects.filter(username__icontains=search).count() if count > 0: @@ -118,6 +120,19 @@ class Discord(discord.Client): else: info = "No applications matched that search." yield from self.discord_message(message.channel, info) + # DEMOTE A PLAYER TO MEMBER + match = re.match("[{0}]demote (\w+)$".format(self.prefix), message.content) + if match: + yield from self.delete_message(message) + username = match.group(1) + api.plugin(api.PLUGIN_DEMOTE, username) + deactivated = "" + if User.objects.filter(username__iexact=username).exists(): + user = User.objects.get(username__iexact=username) + user.is_active = False + user.save() + deactivated = " and de-activated" + yield from self.discord_message(message.channel, "{} has been demoted{}.".format(username, deactivated)) # COMPARE DISCORD USERS TO WHITELIST match = re.match("[{0}]compare".format(self.prefix), message.content) if match: diff --git a/api/models.py b/api/models.py new file mode 100644 index 0000000..28948d8 --- /dev/null +++ b/api/models.py @@ -0,0 +1,25 @@ +from django.db import models +from minecraft_manager.api.api import generate_password + + +class Token(models.Model): + key = models.CharField("Key", default=generate_password, max_length=50, unique=True) + description = models.CharField("Description", max_length=200, blank=True) + active = models.BooleanField("Active", default=True) + web_get_permission = models.BooleanField("Web GET", default=False) + web_post_permission = models.BooleanField("Web POST", default=False) + plugin_get_permission = models.BooleanField("Plugin GET", default=False) + plugin_post_permission = models.BooleanField("Plugin POST", default=False) + form_get_permission = models.BooleanField("Form GET", default=False) + form_post_permission = models.BooleanField("Form POST", default=False) + model_get_permission = models.BooleanField("Model GET", default=False) + model_post_permission = models.BooleanField("Model POST", default=False) + stats_get_permission = models.BooleanField("Stats GET", default=False) + stats_post_permission = models.BooleanField("Stats POST", default=False) + + @property + def display(self): + return self.description if self.description else self.key + + def __str__(self): + return self.key diff --git a/api/views.py b/api/views.py index 7d60377..59c1657 100644 --- a/api/views.py +++ b/api/views.py @@ -1,43 +1,43 @@ from __future__ import absolute_import -import logging, random, string, datetime +import logging, datetime from django.contrib.auth.forms import PasswordChangeForm from django.contrib.auth import update_session_auth_hash from django.apps import apps from django.conf import settings from django.contrib.auth.models import User from django.http import JsonResponse, HttpResponse -from django.urls import reverse from django.utils import timezone from django.views.generic import View from django.forms import modelform_factory -import minecraft_manager.forms as MCMForms +import minecraft_manager.forms as mcm_forms from minecraft_manager.models import Player, UserSettings, Application, IP, Ticket, Warning import minecraft_manager.api.api as mcm_api +from minecraft_manager.api.models import Token import minecraft_manager.utils as mcm_utils import minecraft_manager.external.stats as mcm_stats logger = logging.getLogger(__name__) -def request_allowed(request): +def request_allowed(request, permission): is_authenticated = False if hasattr(request, 'user'): if hasattr(request.user, 'is_authenticated'): is_authenticated = request.user.is_authenticated - password = getattr(settings, 'API_PASSWORD', None) get = request.GET - post= request.POST + post = request.POST request_password = None if 'api' in get: request_password = get['api'] elif 'api' in post: request_password = post['api'] - correct_password = False - if password and request_password: - correct_password = request_password == password - return is_authenticated or correct_password + token_permission = False + if Token.objects.filter(active=True, key=request_password).exists(): + token = Token.objects.get(active=True, key=request_password) + token_permission = getattr(token, permission, False) + return is_authenticated or token_permission def clean(model, data): @@ -51,16 +51,12 @@ def clean(model, data): return cleaned -def generate_password(): - return "".join([random.choice(string.ascii_letters + string.digits) for idx in range(0, 20)]) - - class WebAPI(View): def get(self, request, keyword): get = request.GET data = {'success': False, 'message': 'API failed'} - if request_allowed(request): + if request_allowed(request, 'web_get_permission'): keyword = keyword.lower() if keyword == 'log': html_global = "" @@ -102,7 +98,7 @@ class WebAPI(View): def post(self, request, keyword): post = request.POST data = {} - if request_allowed(request): + if request_allowed(request, 'web_post_permission'): keyword = keyword.lower() if keyword == 'settings' and request.user.usersettings: for s in [a for a in dir(UserSettings) if not a.startswith('__') and not callable(getattr(UserSettings,a))]: @@ -119,7 +115,7 @@ class WebAPI(View): else: return HttpResponse(form.as_p()) elif keyword == 'alert': - form = MCMForms.AlertForm(request.POST) + form = mcm_forms.AlertForm(request.POST) if form.is_valid(): if mcm_api.create_alert(form.cleaned_data['message']): data = {'success': True} @@ -155,7 +151,7 @@ class PluginAPI(View): def get(self, request, keyword): json = {'status': True, 'message': '', 'extra': ''} - if request_allowed(request): + if request_allowed(request, 'plugin_get_permission'): get = request.GET keyword = keyword.lower() @@ -163,7 +159,7 @@ class PluginAPI(View): def post(self, request, keyword): json = {'status': True, 'message': '', 'extra': ''} - if request_allowed(request): + if request_allowed(request, 'plugin_post_permission'): post = request.POST keyword = keyword.lower() if "application" == keyword: @@ -272,10 +268,13 @@ class PluginAPI(View): player.last_seen = timezone.now().strftime("%Y-%m-%d") player.save() if new_player and ip.associated: + associated = [] for assoc in ip.associated: if assoc.uuid is not player.uuid and assoc.is_banned: - mcm_api.plugin("staff", "Server {0}'s IP matches the banned player {1}".format(player.username, assoc.username)) - mcm_api.discord_notification("{0}'s IP matches the banned player {1}".format(player.username, assoc.username), ping=True) + associated.append(assoc) + if associated: + mcm_api.plugin("staff", "Server {0}'s IP matches the banned player(s) {1}".format(player.username, ", ".join([assoc.username for assoc in associated]))) + mcm_api.discord_notification("{0}'s IP matches the banned player(s) {1}".format(player.username, ", ".join([assoc.username for assoc in associated])), ping=True) json['status'] = True json['message'] = "Updated {0}".format(post['username']) elif "register" == keyword: @@ -284,7 +283,7 @@ class PluginAPI(View): json['status'] = False json['message'] = "You are already registered. To change your password, contact an Admin." else: - password = generate_password() + password = mcm_api.generate_password() user = User.objects.create_user(username=player.username.lower(), password=password) user.save() player.auth_user = user @@ -323,7 +322,7 @@ class FormAPI(View): def get(self, request, request_model): html = "" - if request_allowed(request): + if request_allowed(request, 'form_get_permission'): get = request.GET model = None for m in apps.get_app_config('minecraft_manager').get_models(): @@ -332,7 +331,7 @@ class FormAPI(View): break if model: form = None - for modelform in MCMForms.__all__(): + for modelform in mcm_forms.__all__(): if modelform.Meta.model == model: form = modelform() break @@ -346,7 +345,7 @@ class FormAPI(View): def post(self, request, request_model): html = "" - if request_allowed(request): + if request_allowed(request, 'form_post_permission'): post = request.POST model = None for m in apps.get_app_config('minecraft_manager').get_models(): @@ -355,7 +354,7 @@ class FormAPI(View): break if model: form = None - for modelform in MCMForms.__all__(): + for modelform in mcm_forms.__all__(): if modelform.Meta.model == model: form = modelform(post) break @@ -376,7 +375,7 @@ class ModelAPI(View): def get(self, request, request_model): json = [] - if request_allowed(request): + if request_allowed(request, 'model_get_permission'): get = request.GET model = None for m in apps.get_app_config('minecraft_manager').get_models(): @@ -404,7 +403,7 @@ class StatsAPI(View): def get(self, request): json = [] - if request_allowed(request): + if request_allowed(request, 'stats_get_permission'): get = request.GET if 'stat' in get: if 'uuid' in get: diff --git a/docs/source/django-settings.rst b/docs/source/django-settings.rst index 387b2b5..495131d 100644 --- a/docs/source/django-settings.rst +++ b/docs/source/django-settings.rst @@ -47,8 +47,6 @@ Optional ``SERVER_QUERY_IP`` - The full IP (and port) used to query your server. (This is used to get a player list) -``API_PASSWORD`` - The password used to validate API requests from unauthenticated sources. - ``COREPROTECT_WEB_URL`` - The URL to your CoreProtect Web UI, if it exists. ``COREPROTECT_ACTIVITY_URL`` - The URL to your CoreProtect Activity Web UI, if it exists. diff --git a/docs/source/getting-started.rst b/docs/source/getting-started.rst index d24cb3d..72a6031 100644 --- a/docs/source/getting-started.rst +++ b/docs/source/getting-started.rst @@ -47,6 +47,7 @@ Add MCM urls to your ``urls.py`` Django doesn't provide login/logout templates by default, so MCM has some generic ones if needed. :: + from django.contrib.auth import views as auth_views path('accounts/login/', auth_views.LoginView.as_view(template_name='minecraft_manager/login.html'), name='login'), path('accounts/logout/', auth_views.LogoutView.as_view(template_name='minecraft_manager/logged_out.html'), name='logout'), diff --git a/external/stats.py b/external/stats.py index 94b8764..dad52c6 100644 --- a/external/stats.py +++ b/external/stats.py @@ -1,4 +1,4 @@ -import os, json +import os, json, copy from minecraft_manager.models import Player from django.conf import settings @@ -16,9 +16,13 @@ def get_stats(): with open(stats_dir + "/" + filename) as json_file: raw = json.load(json_file)['stats'] clean = {} - for r in raw: - if not any(sf.lower() in r.lower() for sf in stats_filter): - clean[r] = raw[r] + raw_copy = copy.deepcopy(raw) + for ra in raw_copy: + if not any(sf.lower() in ra.lower() for sf in stats_filter): + for r in raw_copy[ra]: + if any(sf.lower() in r.lower() for sf in stats_filter): + del raw[ra][r] + clean[ra] = raw[ra] uuid = filename.replace(".json", "") stats[uuid] = clean return stats diff --git a/overview.py b/overview.py new file mode 100644 index 0000000..3508606 --- /dev/null +++ b/overview.py @@ -0,0 +1,75 @@ +import os +import json +from datetime import datetime, timedelta +from django.conf import settings +from minecraft_manager.models import Application, Player, Ticket, Warning, IP +from django.contrib.auth.models import User + + +def overview_data(): + data = {} + + # Setup + with open(os.path.join(settings.MINECRAFT_BASE_DIR, 'banned-players.json'), encoding='utf-8') as f: + bans = json.load(f) + + + # Totals + data['total'] = { + 'application': { + 'accepted': Application.objects.filter(accepted=True).count(), + 'denied': Application.objects.filter(accepted=False).count(), + 'all': Application.objects.count() + }, + 'player': { + 'banned': len(bans), + 'unbanned': Player.objects.count() - len(bans), + 'all': Player.objects.count() + }, + 'ticket': { + 'claimed': Ticket.objects.filter(staff__isnull=False).count(), + 'unclaimed': Ticket.objects.filter(staff__isnull=True).count(), + 'resolved': Ticket.objects.filter(resolved=True).count(), + 'unresolved': Ticket.objects.filter(resolved=False).count(), + 'all': Ticket.objects.count() + }, + 'warning': Warning.objects.count(), + 'ip': IP.objects.count() + } + + # Averages + data['average'] = { + 'age': round(sum([application.age for application in Application.objects.filter(accepted=True)]) / + data['total']['application']['accepted'] if data['total']['application']['accepted'] != 0 else 1, 2) + } + + # Percentage + data['percentage'] = { + 'accepted': round((data['total']['application']['accepted'] / + data['total']['application']['all'] if data['total']['application']['all'] != 0 else 1) * 100, 2), + 'banned': round((data['total']['player']['banned'] / + data['total']['player']['all'] if data['total']['player']['all'] != 0 else 1) * 100, 2), + 'applied': round((data['total']['application']['all'] / data['total']['player']['all']) * 100, 2) + } + + # Unique logins + now = datetime.now() + day = now - timedelta(days=1) + week = now - timedelta(weeks=1) + month = now - timedelta(weeks=4) + data['unique'] = { + 'day': Player.objects.filter(last_seen__range=[day, now]).count(), + 'week': Player.objects.filter(last_seen__range=[week, now]).count(), + 'month': Player.objects.filter(last_seen__range=[month, now]).count() + } + + # Admin + data['resolved'] = [] + for user in User.objects.all().order_by('username'): + data['resolved'].append({ + 'active': user.is_active, + 'username': user.username, + 'tickets': Ticket.objects.filter(staff=user).count() + }) + + return data diff --git a/templates/minecraft_manager/overview.html b/templates/minecraft_manager/overview.html index 5cd2646..da93c87 100644 --- a/templates/minecraft_manager/overview.html +++ b/templates/minecraft_manager/overview.html @@ -6,141 +6,72 @@ {% endblock %} {% block section %}
+ {% if request.user.is_staff %} +
+
+

Admin Area

+

Resolved Tickets

-
-

Applications{% if form.apps.unanswered > 0 %} -

- + {% for staff in data.resolved %} +
+

{% if staff.active %}Active{% else %}Inactive{% endif %} {{ staff.username }}: {{ staff.tickets }}

+
+ {% endfor %}
-
-
-

Totals

- -
+
+
+
+ {% endif %} +
+
+

Applications: {{ data.total.application.all }}

+

Accepted: {{ data.total.application.accepted }}

+

Denied: {{ data.total.application.denied }}

+
+

Tickets: {{ data.total.ticket.all }}

+

Claimed: {{ data.total.ticket.claimed }}

+

Unclaimed: {{ data.total.ticket.unclaimed }}

+

Resolved: {{ data.total.ticket.resolved }}

+

Unresolved: {{ data.total.ticket.unresolved }}

+
+
+

Players: {{ data.total.player.all }}

+

Not Banned: {{ data.total.player.unbanned }}

+

Banned: {{ data.total.player.banned }}

+
+

Warnings: {{ data.total.warning }}

+
+

IPs: {{ data.total.ip }}

- +
+
+
+
+
+

Acceptance Rate: {{ data.percentage.accepted }}%

+

Application/Player Rate: {{ data.percentage.applied }}%

+
+
+

Ban Rate: {{ data.percentage.banned }}%

+
+
+
+
+
+

Logins Today: {{ data.unique.day }}

+

Logins Last Month: {{ data.unique.month }}

+
+
+

Logins Last Week: {{ data.unique.week }}

+
+
+ {% endblock section %} \ No newline at end of file diff --git a/templatetags/sidebar.py b/templatetags/sidebar.py index 8bae2c4..2060a43 100644 --- a/templatetags/sidebar.py +++ b/templatetags/sidebar.py @@ -17,7 +17,7 @@ def get_sidebar(current_app, request): ret = '
  •   Overview
  • '.format('class="active"' if current_app == 'overview' else "", reverse('overview')) ret += '
  •   Bans
  • '.format('class="active"' if current_app == 'ban' else '', reverse('ban')) - ret += '
  •   Alerts{}
  • '.format('class="active"' if current_app == 'alert' else '', reverse('alert'), unseen_html) + ret += '
  •   Alerts{}
  • '.format('class="active"' if current_app == 'alert' else '', reverse('alert'), unseen_html) # Models ret += '
  •   Applications
  • '.format('class="active"' if current_app == 'application' else '', reverse('application')) diff --git a/views.py b/views.py index d2769e9..46be592 100644 --- a/views.py +++ b/views.py @@ -14,6 +14,7 @@ from django.views.generic import View from django.contrib.auth.models import User from minecraft_manager.models import Application as AppModel, Player as PlayerModel, Ticket as TicketModel, Warning as WarningModel, IP as IPModel, Alert as AlertModel, Note as NoteModel, UserSettings as UserSettingsModel from minecraft_manager.forms import WarningForm, NoteForm +from minecraft_manager.overview import overview_data import minecraft_manager.api.api as API import subprocess @@ -33,19 +34,9 @@ class Overview(View): request.user.usersettings = UserSettingsModel(auth_user=request.user) request.user.usersettings.last_ip = user_ip request.user.usersettings.save() - unanswered_apps = AppModel.objects.filter(accepted=None).count() - accepted_apps = AppModel.objects.filter(accepted=True).count() - denied_apps = AppModel.objects.filter(accepted=False).count() - unclaimed_tickets = TicketModel.objects.filter(staff=None, resolved=False).count() - claimed_tickets = TicketModel.objects.filter(staff__isnull=False, resolved=False).count() - resolved_tickets = TicketModel.objects.filter(resolved=True).count() - counts = {'applications': AppModel.objects.count(), 'players': PlayerModel.objects.count(), - "ips": IPModel.objects.count(), "tickets": TicketModel.objects.count(), - "warnings": WarningModel.objects.count()} - form = {'apps': {'unanswered': unanswered_apps, 'accepted': accepted_apps, 'denied': denied_apps}, - 'tickets': {'unclaimed': unclaimed_tickets, 'claimed': claimed_tickets, 'resolved': resolved_tickets}, - 'counts': counts} - return render(request, 'minecraft_manager/overview.html', {'current_app': 'overview', 'form': form}) + + + return render(request, 'minecraft_manager/overview.html', {'current_app': 'overview', 'data': overview_data()}) class CoreProtect(View):