Skip to content
Merged
5 changes: 4 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
up:
docker compose -f docker-compose.yml up -d
down:
docker compose -f docker-compose.yml down
docker compose -f docker-compose.yml down

run-local:
poetry run daphne -b 0.0.0.0 -p 8000 procollab.asgi:application
10 changes: 9 additions & 1 deletion chats/consumers/chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,15 @@ async def connect(self):
await self.channel_layer.group_add(
EventGroupType.GENERAL_EVENTS, self.channel_name
)
await self.accept()
# Confirm selected subprotocol so browser clients finish handshake.
subprotocol = None
if (
self.scope.get("subprotocols")
and len(self.scope["subprotocols"]) >= 1
):
subprotocol = self.scope["subprotocols"][0]

await self.accept(subprotocol=subprotocol)

async def disconnect(self, close_code):
"""User disconnected from websocket"""
Expand Down
3 changes: 3 additions & 0 deletions core/auth/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
"""
Authentication utilities for ASGI/WebSocket middleware.
"""
36 changes: 19 additions & 17 deletions chats/middleware.py → core/auth/middleware.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
from urllib.parse import parse_qs

import jwt
from channels.db import database_sync_to_async
from django.conf import settings
Expand Down Expand Up @@ -97,33 +95,37 @@ def get_user(scope):

class TokenAuthMiddleware:
"""
Custom middleware that takes a token from the query string and authenticates via Django Rest Framework authtoken.
Custom middleware that takes a token from WebSocket subprotocols and authenticates via JWT.
"""

SUBPROTOCOL_KEYWORD = "Bearer"

def __init__(self, app):
# Store the ASGI application we were passed
self.app = app

async def __call__(self, scope, receive, send):
# Look up user from query string

# TODO: (you should also do things like
# checking if it is a valid user ID, or if scope["user" ] is already
# populated).

query_string = scope["query_string"].decode()
query_dict = parse_qs(query_string)
try:
token = query_dict["token"][0]
if token is None:
raise ValueError("Token is missing from headers")
# Extract token from Sec-WebSocket-Protocol header.
token = self._extract_token_from_subprotocol(scope.get("subprotocols", []))

if token:
scope["token"] = token
scope["user"] = await get_user(scope)
except (ValueError, KeyError, IndexError):
# Token is missing from query string
else:
from django.contrib.auth.models import AnonymousUser

scope["user"] = AnonymousUser()

return await self.app(scope, receive, send)

def _extract_token_from_subprotocol(self, subprotocols: list[str]) -> str | None:
"""
Expect subprotocols in the form ["Bearer", "<JWT>"].
"""
if not subprotocols:
return None

if len(subprotocols) >= 2 and subprotocols[0] == self.SUBPROTOCOL_KEYWORD:
return subprotocols[1]

return None
38 changes: 21 additions & 17 deletions core/log/middleware.py
Original file line number Diff line number Diff line change
@@ -1,30 +1,34 @@
from loguru import logger
from django.conf import settings
import copy
import logging

from django.conf import settings
from loguru import logger

from core.log.utils import InterceptHandler


def _add_logger_handler(path: str, level: str) -> None:
"""
Attach loguru handler, falling back to synchronous mode if multiprocessing
queues are not permitted (e.g. limited dev envs).
"""
kwargs = copy.deepcopy(settings.LOGURU_LOGGING)
try:
logger.add(path, level=level, **kwargs)
except PermissionError:
kwargs.pop("enqueue", None)
logger.add(path, level=level, **kwargs)


class CustomLoguruMiddleware:
def __init__(self, get_response):
self.get_response = get_response
logging.basicConfig(handlers=[InterceptHandler()], level=0, force=True)

if settings.DEBUG:
logger.add(
f"{settings.BASE_DIR}/log/debug.log",
level="DEBUG",
**settings.LOGURU_LOGGING,
)
logger.add(
f"{settings.BASE_DIR}/log/info.log",
level="INFO",
**settings.LOGURU_LOGGING,
)
logger.add(
f"{settings.BASE_DIR}/log/warning.log",
level="WARNING",
**settings.LOGURU_LOGGING,
)
_add_logger_handler(f"{settings.BASE_DIR}/log/debug.log", "DEBUG")
_add_logger_handler(f"{settings.BASE_DIR}/log/info.log", "INFO")
_add_logger_handler(f"{settings.BASE_DIR}/log/warning.log", "WARNING")

def __call__(self, request):
response = self.get_response(request)
Expand Down
10 changes: 6 additions & 4 deletions files/models.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
import reprlib

from django.contrib.auth import get_user_model
from django.conf import settings
from django.db import models

User = get_user_model()


class UserFile(models.Model):
"""
Expand All @@ -20,7 +18,11 @@ class UserFile(models.Model):
"""

link = models.URLField(primary_key=True, null=False)
user = models.ForeignKey(User, on_delete=models.SET_NULL, null=True)
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
)
datetime_uploaded = models.DateTimeField(auto_now_add=True)
name = models.TextField(blank=False, default="file")
extension = models.TextField(blank=True, default="")
Expand Down
13 changes: 8 additions & 5 deletions procollab/asgi.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,19 @@
import os
import chats.routing

from channels.routing import ProtocolTypeRouter, URLRouter
from django.core.asgi import get_asgi_application

from chats.middleware import TokenAuthMiddleware

os.environ.setdefault("DJANGO_SETTINGS_MODULE", "procollab.settings")

# Ensure Django app registry is loaded before importing project routes.
django_asgi_app = get_asgi_application()

from core.auth.middleware import TokenAuthMiddleware # noqa: E402
from procollab.websocket_routing import websocket_urlpatterns # noqa: E402

application = ProtocolTypeRouter(
{
"http": get_asgi_application(),
"websocket": TokenAuthMiddleware(URLRouter(chats.routing.websocket_urlpatterns)),
"http": django_asgi_app,
"websocket": TokenAuthMiddleware(URLRouter(websocket_urlpatterns)),
}
)
4 changes: 4 additions & 0 deletions procollab/websocket_routing.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
from chats.routing import websocket_urlpatterns as chat_websocket_urlpatterns

websocket_urlpatterns = []
websocket_urlpatterns += chat_websocket_urlpatterns
39 changes: 39 additions & 0 deletions projects/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -671,6 +671,43 @@ def patch(self, request, project_pk: int, user_to_leader_pk: int) -> Response:
class DuplicateProjectView(APIView):
permission_classes = [IsAuthenticated, CanBindProjectToProgram]

@staticmethod
def _copy_collaborators(original_project: Project, new_project: Project) -> None:
"""
Copy all collaborators from the source project to the duplicated one.
Keep the leader collaborator (auto-created by signal) in sync with the original.
"""
leader_id = new_project.leader_id
collaborators_to_create: list[Collaborator] = []
leader_collaborator = None

for collaborator in original_project.collaborator_set.select_related("user").all():
if collaborator.user_id == leader_id:
leader_collaborator = collaborator
continue

collaborators_to_create.append(
Collaborator(
user=collaborator.user,
project=new_project,
role=collaborator.role,
specialization=collaborator.specialization,
)
)

if collaborators_to_create:
Collaborator.objects.bulk_create(collaborators_to_create)

if leader_collaborator:
Collaborator.objects.update_or_create(
user_id=leader_id,
project=new_project,
defaults={
"role": leader_collaborator.role,
"specialization": leader_collaborator.specialization,
},
)

@swagger_auto_schema(
request_body=ProjectDuplicateRequestSerializer,
responses={201: ProjectDuplicateRequestSerializer(), 400: "Validation error"},
Expand Down Expand Up @@ -706,6 +743,8 @@ def post(self, request):
cover=original_project.cover,
)

self._copy_collaborators(original_project, new_project)

program_link = PartnerProgramProject.objects.create(
partner_program=partner_program, project=new_project
)
Expand Down
4 changes: 3 additions & 1 deletion scripts/startup.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,6 @@

python manage.py migrate
python manage.py collectstatic --no-input
python manage.py runserver 0.0.0.0:8000

# Use Daphne ASGI server instead of Django's dev server.
exec daphne -b 0.0.0.0 -p 8000 procollab.asgi:application