From ae8496992b03a8d9e5546cb14ecb2d72f8f9e075 Mon Sep 17 00:00:00 2001
From: Erwan Rouchet <rouchet@teklia.com>
Date: Mon, 18 Nov 2019 11:02:51 +0000
Subject: [PATCH] Base Channels setup

---
 Dockerfile                    |  8 +++---
 arkindex/project/asgi.py      | 13 ++++++++++
 arkindex/project/consumers.py | 46 +++++++++++++++++++++++++++++++++++
 arkindex/project/routing.py   | 12 +++++++++
 arkindex/project/settings.py  | 20 +++++++++++++++
 base/requirements.txt         |  2 ++
 requirements.txt              |  2 ++
 7 files changed, 99 insertions(+), 4 deletions(-)
 create mode 100644 arkindex/project/asgi.py
 create mode 100644 arkindex/project/consumers.py
 create mode 100644 arkindex/project/routing.py

diff --git a/Dockerfile b/Dockerfile
index 4472a78372..137fba8302 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,4 +1,4 @@
-FROM registry.gitlab.com/arkindex/backend:base-0.10.1
+FROM registry.gitlab.com/arkindex/backend:base-0.10.3
 
 ARG COMMON_BRANCH=master
 ARG COMMON_ID=9855787
@@ -26,12 +26,12 @@ RUN \
 # Install arkindex and its deps
 # Uses a source archive instead of full local copy to speedup docker build
 COPY dist/arkindex-*.tar.gz /tmp/arkindex.tar.gz
-RUN pip install /tmp/arkindex.tar.gz gunicorn==19.9 && rm /tmp/arkindex.tar.gz
+RUN pip install /tmp/arkindex.tar.gz && rm /tmp/arkindex.tar.gz
 
 # Allow access to logs
 RUN mkdir -p /logs
 RUN chown -R ark:teklia /logs
 
-# Run through supervisor
+# Run with Daphne
 EXPOSE 80
-CMD ["gunicorn", "--access-logfile=-", "--capture-output", "--bind=0.0.0.0:80", "arkindex.project.wsgi"]
+CMD ["daphne", "--verbosity=1", "--bind=0.0.0.0", "--port=80", "arkindex.project.asgi:application"]
diff --git a/arkindex/project/asgi.py b/arkindex/project/asgi.py
new file mode 100644
index 0000000000..0b555c966c
--- /dev/null
+++ b/arkindex/project/asgi.py
@@ -0,0 +1,13 @@
+"""
+ASGI entrypoint. Configures Django and then runs the application
+defined in the ASGI_APPLICATION setting.
+"""
+
+import os
+import django
+from channels.routing import get_default_application
+
+os.environ.setdefault("DJANGO_SETTINGS_MODULE", "arkindex.project.settings")
+os.environ['ALL_CHECKS'] = 'true'
+django.setup()
+application = get_default_application()
diff --git a/arkindex/project/consumers.py b/arkindex/project/consumers.py
new file mode 100644
index 0000000000..45b64e7d5b
--- /dev/null
+++ b/arkindex/project/consumers.py
@@ -0,0 +1,46 @@
+from channels.exceptions import DenyConnection
+from channels.generic.websocket import AsyncJsonWebsocketConsumer
+
+
+class ConsumerPermission(object):
+    """
+    Base class for permission classes on Channels consumers.
+    """
+
+    def has_permission(self, consumer):
+        return True
+
+
+class IsAuthenticated(ConsumerPermission):
+    """
+    Restrict connections to authenticated users.
+    """
+
+    def has_permission(self, consumer):
+        if not consumer.user.is_authenticated:
+            return False
+        return super().has_permission(consumer)
+
+
+class IsAdmin(ConsumerPermission):
+    """
+    Restrict connections to admin users.
+    """
+
+    def has_permission(self, consumer):
+        if not consumer.user.is_authenticated or not consumer.user.is_admin:
+            return False
+        return super().has_permission(consumer)
+
+
+class AuthConsumer(AsyncJsonWebsocketConsumer):
+    permission_classes = ()
+
+    async def connect(self):
+        self.user = self.scope["user"]
+        for permission_class in self.permission_classes:
+            if not permission_class().has_permission(self):
+                # Raise instead of using self.close() because it allows subclasses
+                # to call await super().connect() and continue only when connections are accepted
+                raise DenyConnection
+        await self.accept()
diff --git a/arkindex/project/routing.py b/arkindex/project/routing.py
new file mode 100644
index 0000000000..60f19878e0
--- /dev/null
+++ b/arkindex/project/routing.py
@@ -0,0 +1,12 @@
+from channels.auth import AuthMiddlewareStack
+from channels.routing import ProtocolTypeRouter, URLRouter
+from channels.security.websocket import AllowedHostsOriginValidator
+
+application = ProtocolTypeRouter({
+    'websocket': AllowedHostsOriginValidator(
+        AuthMiddlewareStack(
+            URLRouter([
+            ]),
+        ),
+    ),
+})
diff --git a/arkindex/project/settings.py b/arkindex/project/settings.py
index 324817c9c7..d860f56880 100644
--- a/arkindex/project/settings.py
+++ b/arkindex/project/settings.py
@@ -117,6 +117,7 @@ INSTALLED_APPS = [
     'django_admin_hstore_widget',
 
     # Tools
+    'channels',
     'rest_framework',
     'rest_framework.authtoken',
     'django_filters',
@@ -162,6 +163,7 @@ TEMPLATES = [
 ]
 
 WSGI_APPLICATION = 'arkindex.project.wsgi.application'
+ASGI_APPLICATION = 'arkindex.project.routing.application'
 
 
 # Password validation
@@ -277,6 +279,18 @@ CACHES = {
     }
 }
 
+# Django Channels layer using Redis
+CHANNEL_LAYERS = {
+    "default": {
+        "BACKEND": "channels_redis.core.RedisChannelLayer",
+        "CONFIG": {
+            "hosts": [
+                (os.environ.get('REDIS_HOST', 'localhost'), 6379)
+            ],
+        },
+    },
+}
+
 LOGGING = {
     'version': 1,
     'disable_existing_loggers': False,
@@ -319,6 +333,10 @@ LOGGING = {
             'level': 'INFO',
             'propagate': True,
         },
+        'daphne.server': {
+            'handlers': ['console_debug'],
+            'level': 'INFO',
+        },
         'arkindex.documents.importer': {
             'handlers': ['importers'],
             'level': 'DEBUG',
@@ -363,6 +381,8 @@ CSRF_COOKIE_DOMAIN = os.environ.get('COOKIE_DOMAIN')
 
 SESSION_COOKIE_NAME = 'arkindex.auth'
 SESSION_COOKIE_DOMAIN = os.environ.get('COOKIE_DOMAIN')
+# Required for authentication over websockets
+SESSION_COOKIE_HTTPONLY = False
 
 CORS_ORIGIN_WHITELIST = env2list('CORS_ORIGIN_WHITELIST', default=[
     'universalviewer.io',
diff --git a/base/requirements.txt b/base/requirements.txt
index e35efd23de..97f0507762 100644
--- a/base/requirements.txt
+++ b/base/requirements.txt
@@ -2,9 +2,11 @@ boto3==1.9
 cryptography>=2.7
 Django==2.2
 elasticsearch==6.2.0
+hiredis==1.0.0
 ijson==2.3
 lxml==4.2.3
 openpyxl==2.4.9
 Pillow==4.3.0
 psycopg2==2.7.3.2
 python-Levenshtein==0.12.0
+Twisted==19.7.0
diff --git a/requirements.txt b/requirements.txt
index 0ca760efe9..dddc7b6d1e 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -2,6 +2,8 @@
 
 arkindex-common==0.2.0
 certifi==2017.7.27.1
+channels==2.3.1
+channels-redis==2.4.1
 chardet==3.0.4
 django-admin-hstore-widget==1.0.1
 django-cors-headers==2.4.0
-- 
GitLab