diff --git a/backend/api/urls.py b/backend/api/urls.py index 148c8eae8..6eaa05d11 100644 --- a/backend/api/urls.py +++ b/backend/api/urls.py @@ -39,7 +39,13 @@ router.register(r'octo/g_code_files', octoprint_views.GCodeFileView, 'AgentGCodeFile') + urlpatterns = [ + path('version', viewsets.SlicerApiVersionView.as_view()), + path('printers', viewsets.SlicerApiPrinterListView.as_view()), + path('files/local', viewsets.SlicerApiUploadView.as_view()), + path('v1/apikey/', viewsets.GetApiKeyView.as_view()), + path('v1/onetimeverificationcodes/verify/', # For compatibility with plugin <= 1.7.0 octoprint_views.OneTimeVerificationCodeVerifyView.as_view(), ), diff --git a/backend/api/viewsets.py b/backend/api/viewsets.py index ddf3b180d..af0d64c93 100644 --- a/backend/api/viewsets.py +++ b/backend/api/viewsets.py @@ -3,14 +3,18 @@ import os import time import logging +import re from binascii import hexlify from rest_framework import viewsets, mixins +from rest_framework.views import APIView from rest_framework.permissions import IsAuthenticated from rest_framework.decorators import action from rest_framework.response import Response from rest_framework.exceptions import ValidationError from rest_framework.parsers import MultiPartParser, FormParser, JSONParser +from rest_framework import authentication from rest_framework import status +from rest_framework.authtoken.models import Token from django.utils import timezone from django.conf import settings from django.http import HttpRequest @@ -870,3 +874,99 @@ def list(self, request): serializer = self.serializer_class(results, many=True) return Response(serializer.data) + + +class GetApiKeyView(APIView): + permission_classes = (IsAuthenticated,) + authentication_classes = (CsrfExemptSessionAuthentication,) + + def get(self, request): + token, created = Token.objects.get_or_create(user=request.user) + return Response({'api_key': token.key }) + + +class SlicerApiVersionView(APIView): + authentication_classes = [authentication.TokenAuthentication] + permission_classes = (IsAuthenticated,) + + def get(self, request, format=None): + return Response({"text": "Obico"}) + + +class SlicerApiPrinterListView(APIView): + authentication_classes = [authentication.TokenAuthentication] + permission_classes = (IsAuthenticated,) + + def get(self, request, format=None): + return Response({"printers": [{"id": str(p.id), "name": p.name} for p in request.user.printer_set.all()]}) + + +class SlicerApiUploadView(APIView): + authentication_classes = [authentication.TokenAuthentication] + permission_classes = (IsAuthenticated,) + + def post(self, request, format=None): + data = dict(**request.data) + file = request.data.get('file') + if file: + data['filename'] = file.name + + serializer = GCodeFileDeSerializer(data=data, context={'request': request}) + serializer.is_valid(raise_exception=True) + validated_data = serializer.validated_data + + start_print = request.data.get('print') == 'true' + + printer = None + if start_print: + raw_printer_id = request.data.get('printer_id') + if raw_printer_id: + try: + printer_id = int(re.match(r".*\[(\d+)\]", raw_printer_id).groups()[0]) + except (ValueError, TypeError, IndexError): + raise ValidationError({'printer_id': 'invalid value'}) + + printer = request.user.printer_set.filter(id=printer_id).first() + if printer is None: + raise ValidationError({'printer_id': 'could not find printer'}) + + if 'file' in request.FILES: + file_size_limit = 500 * 1024 * 1024 if request.user.is_pro else 50 * 1024 * 1024 + num_bytes=request.FILES['file'].size + if num_bytes > file_size_limit: + return Response({'error': 'File size too large'}, status=413) + + gcode_file = GCodeFile.objects.create(**validated_data) + + self.set_metadata(gcode_file, *gcode_metadata.parse(request.FILES['file'], num_bytes, request.encoding or settings.DEFAULT_CHARSET)) + + request.FILES['file'].seek(0) + _, ext_url = save_file_obj(self.path_in_storage(gcode_file), request.FILES['file'], settings.GCODE_CONTAINER) + gcode_file.url = ext_url + gcode_file.num_bytes = num_bytes + gcode_file.save() + + if start_print: + # FIXME + return Response({"message": "print queued"}) + + + return Response({"message": "uploaded successfully"}) + + raise ValidationError({"file": "Missing file body"}) + + def set_metadata(self, gcode_file, metadata, thumbnails): + gcode_file.metadata_json = json.dumps(metadata) + for key in ['estimated_time', 'filament_total']: + setattr(gcode_file, key, metadata.get(key)) + + thumb_num = 0 + for thumb in sorted(thumbnails, key=lambda x: x.getbuffer().nbytes, reverse=True): + thumb_num += 1 + if thumb_num > 3: + continue + _, ext_url = save_file_obj(f'gcode_thumbnails/{gcode_file.user.id}/{gcode_file.id}/{thumb_num}.png', thumb, settings.TIMELAPSE_CONTAINER) + setattr(gcode_file, f'thumbnail{thumb_num}_url', ext_url) + + def path_in_storage(self, gcode_file): + return f'{gcode_file.user.id}/{gcode_file.id}' diff --git a/backend/config/settings.py b/backend/config/settings.py index 07e21cba3..1b0464b57 100644 --- a/backend/config/settings.py +++ b/backend/config/settings.py @@ -60,6 +60,7 @@ def get_bool(key, default): 'simple_history', 'widget_tweaks', 'rest_framework', + 'rest_framework.authtoken', 'jstemplate', 'pushbullet', 'corsheaders', diff --git a/frontend/src/components/user-preferences/ApiKey.vue b/frontend/src/components/user-preferences/ApiKey.vue new file mode 100644 index 000000000..ac0a97158 --- /dev/null +++ b/frontend/src/components/user-preferences/ApiKey.vue @@ -0,0 +1,33 @@ + + + diff --git a/frontend/src/config/server-urls.js b/frontend/src/config/server-urls.js index 8e359d0e3..9a6bfc2ef 100644 --- a/frontend/src/config/server-urls.js +++ b/frontend/src/config/server-urls.js @@ -48,4 +48,6 @@ export default { gcodeFileBulkDelete: () => '/api/v1/g_code_files/bulk_delete/', gcodeFolderBulkMove: () => '/api/v1/g_code_folders/bulk_move/', gcodeFileBulkMove: () => '/api/v1/g_code_files/bulk_move/', + + getApiKey: () => '/api/v1/apikey/', } diff --git a/frontend/src/config/user-preferences/routes.js b/frontend/src/config/user-preferences/routes.js index 8052988fb..e9292916b 100644 --- a/frontend/src/config/user-preferences/routes.js +++ b/frontend/src/config/user-preferences/routes.js @@ -4,6 +4,7 @@ const defaultRoutes = { GeneralPreferences: '/user_preferences/general/', ThemePreferences: '/user_preferences/personalization/', ProfilePreferences: '/user_preferences/profile/', + ApiKey: '/user_preferences/api_key/', AuthorizedApps: '/user_preferences/authorized_apps/', GeneralNotifications: '/user_preferences/general_notifications/', PushNotifications: '/user_preferences/mobile_push_notifications/', diff --git a/frontend/src/config/user-preferences/sections.js b/frontend/src/config/user-preferences/sections.js index ad64fab50..21c76f19b 100644 --- a/frontend/src/config/user-preferences/sections.js +++ b/frontend/src/config/user-preferences/sections.js @@ -27,6 +27,13 @@ const defaultSections = { route: routes.ProfilePreferences, isHidden: onlyNotifications(), }, + ApiKey: { + title: 'Api Key', + faIcon: 'fas fa-check-circle', + importComponent: () => import('@src/components/user-preferences/ApiKey'), + route: routes.ApiKey, + isHidden: onlyNotifications(), + }, AuthorizedApps: { title: 'Authorized Apps', faIcon: 'fas fa-check-circle',