diff --git a/.env b/.env new file mode 100644 index 0000000..5be79cf --- /dev/null +++ b/.env @@ -0,0 +1,15 @@ +POSTGRES_PASSWORD=postgres +POSTGRES_USER=postgres +POSTGRES_DB=localcosmos + +DB_HOST=database +DB_USER=postgres +DB_PASSWORD=postgres +DATABASE_NAME=localcosmos + +ALLOWED_HOSTS=localhost +APP_UID=treesofbavaria +SERVE_APP_URL=/ +SECRET_KEY=asjdfkujwefksdbkasdbflaubflasd + +DJANGO_SETTINGS_MODULE=localcosmos_private.settings diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..f207da0 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,18 @@ +FROM python:3.10 + +WORKDIR /opt/localcosmos + +RUN apt-get update &&\ + apt-get install --no-install-recommends -y libgeos-dev libgdal-dev &&\ + apt-get clean autoclean &&\ + apt-get autoremove -y &&\ + rm -rf /var/lib/{apt,dpkg,cache,log}/ + +COPY localcosmos_server/requirements.txt /opt/localcosmos +RUN pip install -r /opt/localcosmos/requirements.txt + +RUN django-admin startproject localcosmos_private + +RUN ls /opt/localcosmos/localcosmos_private + +CMD python manage.py runserver 0.0.0.0:8000 \ No newline at end of file diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..fd2415e --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,25 @@ +version: '3.7' + +services: + app: + build: . + volumes: + - ./docker/localcosmos_private/settings.py:/opt/localcosmos/localcosmos_private/localcosmos_private/settings.py + - ./docker/localcosmos_private/urls.py:/opt/localcosmos/localcosmos_private/localcosmos_private/urls.py + - ./localcosmos_server:/opt/localcosmos/localcosmos_private/localcosmos_server + env_file: .env + depends_on: + - database + ports: + - 8000:8000 + command: sh -c 'cd /opt/localcosmos/localcosmos_private && python manage.py runserver 0.0.0.0:8000' + database: + image: 'postgis/postgis:12-3.3' + volumes: + - database:/var/lib/postgresql/data + ports: + - 5432:5432 + env_file: .env + +volumes: + database: diff --git a/docker/localcosmos_private/settings.py b/docker/localcosmos_private/settings.py index 40570c1..6e0bd4a 100644 --- a/docker/localcosmos_private/settings.py +++ b/docker/localcosmos_private/settings.py @@ -23,7 +23,7 @@ SECRET_KEY = '-l4*f++k5$u!cr(o#-hio-d9hl)9b&nb37%_6v3l^w#20(rr!*' # SECURITY WARNING: don't run with debug turned on in production! -DEBUG = False +DEBUG = True ALLOWED_HOSTS = ['*'] host_list = os.environ.get('ALLOWED_HOSTS', []) @@ -59,7 +59,9 @@ 'django_countries', 'corsheaders', 'rest_framework', - 'rest_framework.authtoken', + "drf_spectacular", + 'rest_framework_simplejwt', + 'rest_framework_simplejwt.token_blacklist', 'octicons', 'imagekit', @@ -117,7 +119,7 @@ 'NAME': os.environ['DATABASE_NAME'], 'USER' : os.environ['DB_USER'], 'PASSWORD' : os.environ['DB_PASSWORD'], - 'HOST' : 'localhost', + 'HOST' : os.environ['DB_HOST'], } } @@ -190,4 +192,5 @@ EMAIL_PORT = os.environ.get('EMAIL_PORT', 25) DEFAULT_FROM_EMAIL = os.environ.get('DEFAULT_FROM_EMAIL', 'webmaster@localhost') - + +from localcosmos_server.settings import * diff --git a/docker/localcosmos_private/urls.py b/docker/localcosmos_private/urls.py index 38eb69a..86ae3d7 100644 --- a/docker/localcosmos_private/urls.py +++ b/docker/localcosmos_private/urls.py @@ -18,7 +18,7 @@ from django.urls import path, include urlpatterns = [ - path('admin/', admin.site.urls), + # path('admin/', admin.site.urls), path('', include('localcosmos_server.urls')), ] @@ -26,3 +26,4 @@ if settings.DEBUG: from django.conf.urls.static import static urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) + urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) diff --git a/localcosmos_server/api/authentication.py b/localcosmos_server/api/authentication.py deleted file mode 100644 index 26b7a1b..0000000 --- a/localcosmos_server/api/authentication.py +++ /dev/null @@ -1,41 +0,0 @@ -from django.contrib.auth import get_user_model - -from rest_framework.authentication import BaseAuthentication -from rest_framework.authtoken.models import Token -from rest_framework.exceptions import AuthenticationFailed - -User = get_user_model() - -class LCTokenAuthentication(BaseAuthentication): - - def authenticate(self, request): - auth = request.META.get('HTTP_AUTHORIZATION', '').split() - if not auth or auth[0].lower() != 'token': - return None - - if len(auth) == 1: - msg = 'Invalid token header. No credentials provided.' - raise AuthenticationFailed(msg) - elif len(auth) > 2: - msg = 'Invalid token header. Token string should not contain spaces.' - raise AuthenticationFailed(msg) - - key = auth[1] - - try: - token = Token.objects.get(key=key) - except Token.DoesNotExist: - raise AuthenticationFailed('Invalid token') - - try: - user = User.objects.get(pk=token.user_id) - except User.DoesNotExist: - raise AuthenticationFailed('User inactive or deleted') - - - if user.is_active == True: - return (user, token) - - else: - msg = 'User inactive.' - raise AuthenticationFailed(msg) diff --git a/localcosmos_server/api/serializers.py b/localcosmos_server/api/serializers.py index b3a4226..432ad26 100644 --- a/localcosmos_server/api/serializers.py +++ b/localcosmos_server/api/serializers.py @@ -39,6 +39,8 @@ def update_datasets(self, user, client): # this uses email or username + # todo: subclass this into the simplejwt: + # see: https://django-rest-framework-simplejwt.readthedocs.io/en/latest/customizing_token_claims.html def validate(self, attrs): username = attrs.get('username') password = attrs.get('password') diff --git a/localcosmos_server/api/urls.py b/localcosmos_server/api/urls.py index ce40b1d..b34feb7 100644 --- a/localcosmos_server/api/urls.py +++ b/localcosmos_server/api/urls.py @@ -6,14 +6,12 @@ urlpatterns = [ # app unspecific path('', views.APIHome.as_view(), name='api_home'), - path('auth-token/', views.ObtainLCAuthToken.as_view()), - path('user//manage/', views.ManageAccount.as_view()), - path('user//delete/', views.DeleteAccount.as_view()), + path('user/manage/', views.ManageAccount.as_view()), + path('user/delete/', views.DeleteAccount.as_view()), path('user/register/', views.RegisterAccount.as_view()), path('password/reset/', views.PasswordResetRequest.as_view()), # app specific path('app//', views.AppAPIHome.as_view(), name='app_api_home'), - path('app//privacy-statement/', views.PrivacyStatement.as_view(), name='app_privacy_statement'), ] urlpatterns = format_suffix_patterns(urlpatterns, allowed=['json', 'html']) diff --git a/localcosmos_server/api/views.py b/localcosmos_server/api/views.py index e1ad2c8..03f5a37 100644 --- a/localcosmos_server/api/views.py +++ b/localcosmos_server/api/views.py @@ -12,19 +12,17 @@ from django.utils.translation import gettext_lazy as _ from rest_framework.views import APIView -from rest_framework.exceptions import ParseError, NotFound from rest_framework.response import Response -from rest_framework.renderers import TemplateHTMLRenderer -from rest_framework.authtoken.models import Token +from rest_framework.renderers import TemplateHTMLRenderer, JSONRenderer +from drf_spectacular.utils import inline_serializer, extend_schema +from rest_framework_simplejwt.authentication import JWTAuthentication from rest_framework import status from localcosmos_server.models import App - -from .serializers import LCAuthTokenSerializer, AccountSerializer, RegistrationSerializer, PasswordResetSerializer from django_road.permissions import IsAuthenticatedOnly, OwnerOnly -from .authentication import LCTokenAuthentication +from .serializers import AccountSerializer, RegistrationSerializer, PasswordResetSerializer from localcosmos_server.mails import send_registration_confirmation_email import os, json @@ -37,56 +35,30 @@ ################################################################################################################## -''' - APIHome +class APIHome(APIView): + """ - does not require an app uuid - displays the status of the api -''' -class APIHome(APIView): + """ def get(self, request, *args, **kwargs): return Response({'success':True}) -''' - APIDocumentation - - displays endpoints -''' class APIDocumentation(APIView): + """ + - displays endpoints + """ pass -''' - Token Authentication - - does not require an app uuid - - you authenticate with the server, app-unspecific - - the app-specific api calls depend on the app-specific user role -''' -from rest_framework.authtoken.views import ObtainAuthToken -class ObtainLCAuthToken(ObtainAuthToken): - serializer_class = LCAuthTokenSerializer - - # we have tro pass the uuid to the app, rest_framework.authtoken.views.ObtainAuthToken does not do this - def post(self, request, *args, **kwargs): - serializer = self.serializer_class(data=request.data, - context={'request': request}) - serializer.is_valid(raise_exception=True) - user = serializer.validated_data['user'] - user.last_login = timezone.now() - user.save() - token, created = Token.objects.get_or_create(user=user) - return Response({'token': token.key, 'uuid':user.uuid}) - - - -''' - User Account Registration, App specific -''' class RegisterAccount(APIView): + """ + User Account Registration, App specific + """ permission_classes = () - renderer_classes = (TemplateHTMLRenderer,) - template_name = 'localcosmos_server/api/register_account.html' + renderer_classes = (JSONRenderer,) serializer_class = RegistrationSerializer @@ -107,52 +79,47 @@ def get(self, request, *args, **kwargs): # this is for creating only def post(self, request, *args, **kwargs): - - serializer_context = { 'request': request } + serializer_context = { 'request': request } serializer = self.serializer_class(data=request.data, context=serializer_context) - context = { - 'user': request.user, - 'success' : False, - 'request' : request, - } - + context = { 'success' : False, } + if serializer.is_valid(): app_uuid = serializer.validated_data['app_uuid'] user = serializer.save() request.user = user - context['user'] = user + context['user'] = AccountSerializer(user).data context['success'] = True # send registration email - send_registration_confirmation_email(user, app_uuid) + try: + send_registration_confirmation_email(user, app_uuid) + except: + # todo: log? + pass else: context['success'] = False - context['serializer'] = serializer + context['errors'] = serializer.errors return Response(context, status=status.HTTP_400_BAD_REQUEST) # account creation was successful - context['serializer'] = self.serializer_class(data=request.data) - return Response(context) - - + return Response(context) -''' - Manage Account - - authenticated users only - - owner only - - [GET] delivers the form html to the client - - [POST] validates and saves - and returns html -''' class ManageAccount(APIView): + ''' + Manage Account + - authenticated users only + - owner only + - [GET] delivers the form html to the client + - [POST] validates and saves - and returns html + ''' permission_classes = (IsAuthenticatedOnly, OwnerOnly) - authentication_classes = (LCTokenAuthentication,) - renderer_classes = (TemplateHTMLRenderer,) - template_name = 'localcosmos_server/api/manage_account.html' + authentication_classes = (JWTAuthentication,) + renderer_classes = (JSONRenderer,) serializer_class = AccountSerializer def get_object(self): @@ -162,12 +129,9 @@ def get_object(self): def get(self, request, *args, **kwargs): serializer = self.serializer_class(request.user) - serializer_context = { - 'serializer': serializer, - 'user': request.user, - 'request':request - } - return Response(serializer_context) + return Response({ + 'user': serializer.data + }) # this is for updating only def put(self, request, *args, **kwargs): @@ -203,7 +167,7 @@ def put(self, request, *args, **kwargs): class DeleteAccount(APIView): permission_classes = (IsAuthenticatedOnly, OwnerOnly) - authentication_classes = (LCTokenAuthentication,) + authentication_classes = (JWTAuthentication,) renderer_classes = (TemplateHTMLRenderer,) template_name = 'localcosmos_server/api/delete_account.html' serializer_class = AccountSerializer @@ -298,6 +262,12 @@ def post(self, request, *args, **kwargs): ''' class AppAPIHome(APIView): + @extend_schema( + responses=inline_serializer('App', { + 'api_status': str, + 'app_name': str, + }) + ) def get(self, request, *args, **kwargs): app = App.objects.get(uuid=kwargs['app_uuid']) context = { @@ -305,37 +275,3 @@ def get(self, request, *args, **kwargs): 'app_name' : app.name, } return Response(context) - - -################################################################################################################## -# -# LEGAL REQUIREMENTS -# -################################################################################################################## -class PrivacyStatement(APIView): - - permission_classes = () - renderer_classes = (TemplateHTMLRenderer,) - template_name = 'localcosmos_server/api/legal/privacy_statement.html' - - - def get(self, request, *args, **kwargs): - - app = App.objects.get(uuid=kwargs['app_uuid']) - - # get app legal_notice.json - review = request.GET.get('review', False) - if review: - legal_notice_path = os.path.join(app.review_version_path, 'legal_notice.json') - else: - legal_notice_path = os.path.join(app.published_version_path, 'legal_notice.json') - - with open(legal_notice_path, 'r') as f: - legal_notice = json.loads(f.read()) - - context = { - 'legal_notice' : legal_notice, - 'request':request, - } - - return Response(context) diff --git a/localcosmos_server/requirements.txt b/localcosmos_server/requirements.txt index 22973f1..49eebdb 100644 --- a/localcosmos_server/requirements.txt +++ b/localcosmos_server/requirements.txt @@ -10,5 +10,7 @@ django-el-pagination==3.3.0 django-octicons==1.0.2 django-countries==6.1.3 django-cors-headers==3.5.0 +drf-spectacular +djangorestframework-simplejwt Pillow matplotlib diff --git a/localcosmos_server/settings.py b/localcosmos_server/settings.py index 44ca606..7769c81 100644 --- a/localcosmos_server/settings.py +++ b/localcosmos_server/settings.py @@ -1,6 +1,8 @@ ''' LOCALCOSMOS SERVER DJANGO SETTINGS ''' +from datetime import timedelta + from django.utils.translation import ugettext_lazy as _ @@ -8,6 +10,7 @@ FORM_RENDERER = 'django.forms.renderers.TemplatesSetting' +LAZY_TAXONOMY_SOURCES = [] AUTHENTICATION_BACKENDS = ( 'rules.permissions.ObjectPermissionBackend', @@ -50,21 +53,35 @@ # enable token authentication only for API REST_FRAMEWORK = { 'DEFAULT_AUTHENTICATION_CLASSES': ( - 'localcosmos_server.api.authentication.LCTokenAuthentication', + 'rest_framework_simplejwt.authentication.JWTAuthentication', ), #'DEFAULT_FILTER_BACKENDS': ( # 'rest_framework.filters.DjangoFilterBackend', #), + 'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema', 'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.LimitOffsetPagination', 'PAGE_SIZE': 25, } +SPECTACULAR_SETTINGS = { + 'TITLE': 'Localcosmos Server API', + 'DESCRIPTION': 'API Documentation for the Localcosmos Server', + 'VERSION': '1.0.0', + 'SERVE_INCLUDE_SCHEMA': False, + 'PREPROCESSING_HOOKS': ['localcosmos_server.utils.api_filter_endpoints_hook'], +} + DATASET_VALIDATION_CLASSES = ( #'localcosmos_server.datasets.validation.ReferenceFieldsValidator', # unfinished 'localcosmos_server.datasets.validation.ExpertReviewValidator', ) +SIMPLE_JWT = { + 'ACCESS_TOKEN_LIFETIME': timedelta(minutes=60), + 'REFRESH_TOKEN_LIFETIME': timedelta(days=30), +} + LOCALCOSMOS_ENABLE_GOOGLE_CLOUD_API = False LOGIN_REDIRECT_URL = '/server/control-panel/' diff --git a/localcosmos_server/templates/swagger-ui.html b/localcosmos_server/templates/swagger-ui.html new file mode 100644 index 0000000..2199c24 --- /dev/null +++ b/localcosmos_server/templates/swagger-ui.html @@ -0,0 +1,28 @@ + + + + Swagger + + + + + +
+ + + + \ No newline at end of file diff --git a/localcosmos_server/urls.py b/localcosmos_server/urls.py index f3fb22e..1b8d1fd 100644 --- a/localcosmos_server/urls.py +++ b/localcosmos_server/urls.py @@ -1,6 +1,8 @@ from django.conf import settings from django.contrib import admin from django.urls import include, path +from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView +from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView, TokenBlacklistView from . import views @@ -20,7 +22,13 @@ path('api/', include('localcosmos_server.api.urls')), path('api/', include('localcosmos_server.online_content.api.urls')), path('api/anycluster/', include('localcosmos_server.anycluster_schema_urls')), - + + path('api/token/', TokenObtainPairView.as_view(), name='token_obtain_pair'), + path('api/token/refresh/', TokenRefreshView.as_view(), name='token_refresh'), + path('api/token/blacklist/', TokenBlacklistView.as_view(), name='token_blacklist'), + + path("schema/", SpectacularAPIView.as_view(), name="schema"), + path("docs/", SpectacularSwaggerView.as_view(template_name="swagger-ui.html", url_name="schema"), name="swagger-ui"), ] if getattr(settings, 'LOCALCOSMOS_ENABLE_GOOGLE_CLOUD_API', False) == True: diff --git a/localcosmos_server/utils.py b/localcosmos_server/utils.py index 35cc3d3..71956fa 100644 --- a/localcosmos_server/utils.py +++ b/localcosmos_server/utils.py @@ -1,3 +1,6 @@ +import re + + def get_domain_name(request): setup_domain_name = request.get_host().split(request.get_port())[0].split(':')[0] return setup_domain_name @@ -19,3 +22,12 @@ def datetime_from_cron(cron): timestamp = datetime.fromtimestamp(local, tz=tz) return timestamp + +def api_filter_endpoints_hook(endpoints): + # for (path, path_regex, method, callback) in endpoints: + # pass + # drop html endpoints + endpoints = [endpoint for endpoint in endpoints if not endpoint[0].endswith("{format}")] + exposed_endpoints = [endpoint for endpoint in endpoints if re.match('/api/(user|app|password|online-content|token)/', endpoint[0])] + + return exposed_endpoints \ No newline at end of file