From 34cd290efbeca242f1f15412a788ae1eaef3073c Mon Sep 17 00:00:00 2001 From: craftxbox Date: Sun, 10 Aug 2025 23:20:11 -0230 Subject: [PATCH 01/13] Rename classes & refactor fork token handling flow --- .../Auth/CreateAccount/Invalid identifier.bru | 20 -- api-collection/Auth/CreateAccount/Valid.bru | 20 -- .../LoginWithCreds/non existent account.bru | 18 - .../Auth/LoginWithCreds/not verified.bru | 18 - .../Auth/LoginWithCreds/success.bru | 18 - .../Auth/LoginWithToken/invalid token.bru | 22 -- .../Auth/LoginWithToken/missing token.bru | 18 - .../Auth/LoginWithToken/success.bru | 22 -- .../Auth/Mail confirmation/ConfirmAccount.bru | 17 - .../Resend mail confirmation.bru | 17 - .../Auth/ResendConfirmationMail/Valid.bru | 17 - .../Auth/ResetPassword/confirm the reset.bru | 17 - .../Auth/ResetPassword/request a reset.bru | 17 - api-collection/Auth/SHAChecks/check token.bru | 18 - api-collection/Auth/SHAChecks/folder.bru | 3 - .../Auth/SHAChecks/register token.bru | 21 -- api-collection/Characters/Create.bru | 26 -- api-collection/Characters/Delete.bru | 15 - .../Characters/Get charracter token.bru | 21 -- api-collection/Characters/GetAll.bru | 15 - api-collection/Characters/GetCharacter.bru | 15 - .../Characters/GetCompatible Token.bru | 19 -- api-collection/Characters/GetCompatible.bru | 20 -- api-collection/Characters/Update.bru | 27 -- api-collection/admin/folder.bru | 3 - api-collection/bruno.json | 5 - api-collection/environments/development.bru | 3 - api-collection/environments/local.bru | 3 - src/accounts/api/serializers.py | 6 + src/accounts/api/urls.py | 8 +- src/accounts/api/views.py | 82 ++--- src/accounts/migrations/0005_sha512token.py | 24 -- src/accounts/models.py | 14 +- src/central_command/settings.py | 3 + src/persistence/api/permissions.py | 32 ++ src/persistence/api/views.py | 315 ++---------------- 36 files changed, 118 insertions(+), 821 deletions(-) delete mode 100644 api-collection/Auth/CreateAccount/Invalid identifier.bru delete mode 100644 api-collection/Auth/CreateAccount/Valid.bru delete mode 100644 api-collection/Auth/LoginWithCreds/non existent account.bru delete mode 100644 api-collection/Auth/LoginWithCreds/not verified.bru delete mode 100644 api-collection/Auth/LoginWithCreds/success.bru delete mode 100644 api-collection/Auth/LoginWithToken/invalid token.bru delete mode 100644 api-collection/Auth/LoginWithToken/missing token.bru delete mode 100644 api-collection/Auth/LoginWithToken/success.bru delete mode 100644 api-collection/Auth/Mail confirmation/ConfirmAccount.bru delete mode 100644 api-collection/Auth/Mail confirmation/Resend mail confirmation.bru delete mode 100644 api-collection/Auth/ResendConfirmationMail/Valid.bru delete mode 100644 api-collection/Auth/ResetPassword/confirm the reset.bru delete mode 100644 api-collection/Auth/ResetPassword/request a reset.bru delete mode 100644 api-collection/Auth/SHAChecks/check token.bru delete mode 100644 api-collection/Auth/SHAChecks/folder.bru delete mode 100644 api-collection/Auth/SHAChecks/register token.bru delete mode 100644 api-collection/Characters/Create.bru delete mode 100644 api-collection/Characters/Delete.bru delete mode 100644 api-collection/Characters/Get charracter token.bru delete mode 100644 api-collection/Characters/GetAll.bru delete mode 100644 api-collection/Characters/GetCharacter.bru delete mode 100644 api-collection/Characters/GetCompatible Token.bru delete mode 100644 api-collection/Characters/GetCompatible.bru delete mode 100644 api-collection/Characters/Update.bru delete mode 100644 api-collection/admin/folder.bru delete mode 100644 api-collection/bruno.json delete mode 100644 api-collection/environments/development.bru delete mode 100644 api-collection/environments/local.bru delete mode 100644 src/accounts/migrations/0005_sha512token.py create mode 100644 src/persistence/api/permissions.py diff --git a/api-collection/Auth/CreateAccount/Invalid identifier.bru b/api-collection/Auth/CreateAccount/Invalid identifier.bru deleted file mode 100644 index f43f97c..0000000 --- a/api-collection/Auth/CreateAccount/Invalid identifier.bru +++ /dev/null @@ -1,20 +0,0 @@ -meta { - name: Invalid identifier - type: http - seq: 2 -} - -post { - url: {{baseUrl}}/accounts/register - body: json - auth: none -} - -body:json { - { - "unique_identifier": "' 'DROP TABLE USERS;", - "username": " ", - "password": "wasd123", - "email": "mynameemail.com" - } -} diff --git a/api-collection/Auth/CreateAccount/Valid.bru b/api-collection/Auth/CreateAccount/Valid.bru deleted file mode 100644 index a9a5e87..0000000 --- a/api-collection/Auth/CreateAccount/Valid.bru +++ /dev/null @@ -1,20 +0,0 @@ -meta { - name: Valid - type: http - seq: 1 -} - -post { - url: http://localhost:8000/accounts/register - body: json - auth: none -} - -body:json { - { - "unique_identifier": "myname", - "username": "Name", - "password": "qweasd123", - "email": "myname@email.com" - } -} diff --git a/api-collection/Auth/LoginWithCreds/non existent account.bru b/api-collection/Auth/LoginWithCreds/non existent account.bru deleted file mode 100644 index 86d2d3f..0000000 --- a/api-collection/Auth/LoginWithCreds/non existent account.bru +++ /dev/null @@ -1,18 +0,0 @@ -meta { - name: non existent account - type: http - seq: 3 -} - -post { - url: {{baseUrl}}/accounts/login-credentials - body: json - auth: none -} - -body:json { - { - "email": "doesnot@exists.com", - "password": "qweasd123" - } -} diff --git a/api-collection/Auth/LoginWithCreds/not verified.bru b/api-collection/Auth/LoginWithCreds/not verified.bru deleted file mode 100644 index 112698f..0000000 --- a/api-collection/Auth/LoginWithCreds/not verified.bru +++ /dev/null @@ -1,18 +0,0 @@ -meta { - name: not verified - type: http - seq: 2 -} - -post { - url: {{baseUrl}}/accounts/login-credentials - body: json - auth: none -} - -body:json { - { - "email": "myname@email.com", - "password": "qweasd123" - } -} diff --git a/api-collection/Auth/LoginWithCreds/success.bru b/api-collection/Auth/LoginWithCreds/success.bru deleted file mode 100644 index 1148a94..0000000 --- a/api-collection/Auth/LoginWithCreds/success.bru +++ /dev/null @@ -1,18 +0,0 @@ -meta { - name: success - type: http - seq: 1 -} - -post { - url: http://127.0.0.1:8000/accounts/login-credentials - body: json - auth: none -} - -body:json { - { - "email": "bob@bob.bob", - "password": "bob" - } -} diff --git a/api-collection/Auth/LoginWithToken/invalid token.bru b/api-collection/Auth/LoginWithToken/invalid token.bru deleted file mode 100644 index 279309f..0000000 --- a/api-collection/Auth/LoginWithToken/invalid token.bru +++ /dev/null @@ -1,22 +0,0 @@ -meta { - name: invalid token - type: http - seq: 3 -} - -post { - url: {{baseUrl}}/accounts/login-token - body: json - auth: none -} - -headers { - Authorization: Token 6dc6178ad72f5beA0581b6b49024cdbb41d85ffdd1fbbf40991cce24a69a327b -} - -body:json { - { - "username": "admin@admin.com", - "password": "admin" - } -} diff --git a/api-collection/Auth/LoginWithToken/missing token.bru b/api-collection/Auth/LoginWithToken/missing token.bru deleted file mode 100644 index 98d5a4a..0000000 --- a/api-collection/Auth/LoginWithToken/missing token.bru +++ /dev/null @@ -1,18 +0,0 @@ -meta { - name: missing token - type: http - seq: 2 -} - -post { - url: {{baseUrl}}/accounts/login-token - body: json - auth: none -} - -body:json { - { - "username": "admin@admin.com", - "password": "admin" - } -} diff --git a/api-collection/Auth/LoginWithToken/success.bru b/api-collection/Auth/LoginWithToken/success.bru deleted file mode 100644 index ea76d4c..0000000 --- a/api-collection/Auth/LoginWithToken/success.bru +++ /dev/null @@ -1,22 +0,0 @@ -meta { - name: success - type: http - seq: 1 -} - -post { - url: {{baseUrl}}/accounts/login-token - body: json - auth: none -} - -headers { - Authorization: Token 6dc6178ad72f5bed0581b6b49024cdbb41d85ffdd1fbbf40991cce24a69a327b -} - -body:json { - { - "username": "admin@admin.com", - "password": "admin" - } -} diff --git a/api-collection/Auth/Mail confirmation/ConfirmAccount.bru b/api-collection/Auth/Mail confirmation/ConfirmAccount.bru deleted file mode 100644 index 2be10dc..0000000 --- a/api-collection/Auth/Mail confirmation/ConfirmAccount.bru +++ /dev/null @@ -1,17 +0,0 @@ -meta { - name: ConfirmAccount - type: http - seq: 1 -} - -post { - url: {{baseUrl}}/accounts/confirm-account - body: json - auth: none -} - -body:json { - { - "token": "asdasdasdasdasd" - } -} diff --git a/api-collection/Auth/Mail confirmation/Resend mail confirmation.bru b/api-collection/Auth/Mail confirmation/Resend mail confirmation.bru deleted file mode 100644 index c89db03..0000000 --- a/api-collection/Auth/Mail confirmation/Resend mail confirmation.bru +++ /dev/null @@ -1,17 +0,0 @@ -meta { - name: Resend mail confirmation - type: http - seq: 2 -} - -post { - url: {{baseUrl}}/accounts/resend-account-confirmation - body: json - auth: none -} - -body:json { - { - "email": "mail@mail.com" - } -} diff --git a/api-collection/Auth/ResendConfirmationMail/Valid.bru b/api-collection/Auth/ResendConfirmationMail/Valid.bru deleted file mode 100644 index b330560..0000000 --- a/api-collection/Auth/ResendConfirmationMail/Valid.bru +++ /dev/null @@ -1,17 +0,0 @@ -meta { - name: Valid - type: http - seq: 1 -} - -post { - url: {{baseUrl}}/accounts/resend-account-confirmation - body: json - auth: none -} - -body:json { - { - "email": "mail@mail.com" - } -} diff --git a/api-collection/Auth/ResetPassword/confirm the reset.bru b/api-collection/Auth/ResetPassword/confirm the reset.bru deleted file mode 100644 index 083d350..0000000 --- a/api-collection/Auth/ResetPassword/confirm the reset.bru +++ /dev/null @@ -1,17 +0,0 @@ -meta { - name: confirm the reset - type: http - seq: 2 -} - -post { - url: {{baseUrl}}/accounts/reset-password/ - body: json - auth: none -} - -body:json { - { - "password": "admin" - } -} diff --git a/api-collection/Auth/ResetPassword/request a reset.bru b/api-collection/Auth/ResetPassword/request a reset.bru deleted file mode 100644 index ba36d97..0000000 --- a/api-collection/Auth/ResetPassword/request a reset.bru +++ /dev/null @@ -1,17 +0,0 @@ -meta { - name: request a reset - type: http - seq: 1 -} - -post { - url: {{baseUrl}}/accounts/reset-password/ - body: json - auth: none -} - -body:json { - { - "email": "admin@admin.com" - } -} diff --git a/api-collection/Auth/SHAChecks/check token.bru b/api-collection/Auth/SHAChecks/check token.bru deleted file mode 100644 index b97f56a..0000000 --- a/api-collection/Auth/SHAChecks/check token.bru +++ /dev/null @@ -1,18 +0,0 @@ -meta { - name: check token - type: http - seq: 2 -} - -post { - url: http://127.0.0.1:8000/accounts/check-SHA512-for-account/ - body: json - auth: none -} - -body:json { - { - "sha512_token" : "AA", - "unique_identifier" : "bob" - } -} diff --git a/api-collection/Auth/SHAChecks/folder.bru b/api-collection/Auth/SHAChecks/folder.bru deleted file mode 100644 index a531e7b..0000000 --- a/api-collection/Auth/SHAChecks/folder.bru +++ /dev/null @@ -1,3 +0,0 @@ -meta { - name: SHAChecks -} diff --git a/api-collection/Auth/SHAChecks/register token.bru b/api-collection/Auth/SHAChecks/register token.bru deleted file mode 100644 index fe9aeca..0000000 --- a/api-collection/Auth/SHAChecks/register token.bru +++ /dev/null @@ -1,21 +0,0 @@ -meta { - name: register token - type: http - seq: 1 -} - -post { - url: http://127.0.0.1:8000/accounts/register-SHA512-for-account/ - body: json - auth: inherit -} - -headers { - Authorization: Token 894bf0bfd6ef6c05feb6a3447dfc2f3a2fb0147cd7da498d2843021794297cf0 -} - -body:json { - { - "sha512_token" : "AA" - } -} diff --git a/api-collection/Characters/Create.bru b/api-collection/Characters/Create.bru deleted file mode 100644 index 6fdf5b6..0000000 --- a/api-collection/Characters/Create.bru +++ /dev/null @@ -1,26 +0,0 @@ -meta { - name: Create - type: http - seq: 1 -} - -post { - url: {{baseUrl}}/persistence/characters/create - body: json - auth: none -} - -headers { - Authorization: Token 4963885504960842e61c6cadb4a9df05647e2c7bf1cfe08ea8cff57ab37058ac -} - -body:json { - { - "character_sheet_version": "1.0.0", - "fork_compatibility": "Not compatible", - "data": { - "name": "My Name", - "age": 31 - } - } -} diff --git a/api-collection/Characters/Delete.bru b/api-collection/Characters/Delete.bru deleted file mode 100644 index a4d03d2..0000000 --- a/api-collection/Characters/Delete.bru +++ /dev/null @@ -1,15 +0,0 @@ -meta { - name: Delete - type: http - seq: 5 -} - -delete { - url: {{baseUrl}}/persistence/characters/7/delete - body: none - auth: none -} - -headers { - Authorization: Token 4963885504960842e61c6cadb4a9df05647e2c7bf1cfe08ea8cff57ab37058ac -} diff --git a/api-collection/Characters/Get charracter token.bru b/api-collection/Characters/Get charracter token.bru deleted file mode 100644 index f8f9335..0000000 --- a/api-collection/Characters/Get charracter token.bru +++ /dev/null @@ -1,21 +0,0 @@ -meta { - name: Get charracter token - type: http - seq: 7 -} - -post { - url: http://127.0.0.1:8000/persistence/characters/GenForkToken - body: json - auth: none -} - -headers { - Authorization: Token 7cac756254c5574dbdd69e2129394337158b7929446576ede3fb2a43e179540c -} - -body:json { - { - "fork_compatibility": "UnityStationDevelop" - } -} diff --git a/api-collection/Characters/GetAll.bru b/api-collection/Characters/GetAll.bru deleted file mode 100644 index cebbd46..0000000 --- a/api-collection/Characters/GetAll.bru +++ /dev/null @@ -1,15 +0,0 @@ -meta { - name: GetAll - type: http - seq: 3 -} - -get { - url: http://127.0.0.1:8000/persistence/characters - body: none - auth: none -} - -headers { - Authorization: Token a74030290fa0dbc6d85f2e8dd885bbb76d76d9dedfe7c82bc60d72e8bd09210c -} diff --git a/api-collection/Characters/GetCharacter.bru b/api-collection/Characters/GetCharacter.bru deleted file mode 100644 index 5f62c64..0000000 --- a/api-collection/Characters/GetCharacter.bru +++ /dev/null @@ -1,15 +0,0 @@ -meta { - name: GetCharacter - type: http - seq: 2 -} - -get { - url: {{baseUrl}}/persistence/characters/8 - body: none - auth: none -} - -headers { - Authorization: Token 4963885504960842e61c6cadb4a9df05647e2c7bf1cfe08ea8cff57ab37058ac -} diff --git a/api-collection/Characters/GetCompatible Token.bru b/api-collection/Characters/GetCompatible Token.bru deleted file mode 100644 index 93a529c..0000000 --- a/api-collection/Characters/GetCompatible Token.bru +++ /dev/null @@ -1,19 +0,0 @@ -meta { - name: GetCompatible Token - type: http - seq: 8 -} - -get { - url: http://localhost:8000/persistence/characters/compatibleToken?character_sheet_version=1.0.0 - body: none - auth: none -} - -params:query { - character_sheet_version: 1.0.0 -} - -headers { - X-Character-Token: eyJzZXJ2ZXJfaWQiOiJVbml0eVN0YXRpb25EZXZlbG9wIiwidXVpZCI6ImJvYiIsIm5vbmNlIjoiMTFkMTI3NzdhNTZlNWViZCJ9:1ukm1R:-l14SCkeDVJHIx9_J8fAUeQDIoHCcDavviBmqtpqcBo -} diff --git a/api-collection/Characters/GetCompatible.bru b/api-collection/Characters/GetCompatible.bru deleted file mode 100644 index a5e067e..0000000 --- a/api-collection/Characters/GetCompatible.bru +++ /dev/null @@ -1,20 +0,0 @@ -meta { - name: GetCompatible - type: http - seq: 3 -} - -get { - url: {{baseUrl}}/persistence/characters/compatible?fork_compatibility=Unitystation&character_sheet_version=1.0.0 - body: none - auth: none -} - -query { - fork_compatibility: Unitystation - character_sheet_version: 1.0.0 -} - -headers { - Authorization: Token 4963885504960842e61c6cadb4a9df05647e2c7bf1cfe08ea8cff57ab37058ac -} diff --git a/api-collection/Characters/Update.bru b/api-collection/Characters/Update.bru deleted file mode 100644 index 4ec818f..0000000 --- a/api-collection/Characters/Update.bru +++ /dev/null @@ -1,27 +0,0 @@ -meta { - name: Update - type: http - seq: 6 -} - -patch { - url: {{baseUrl}}/persistence/characters/8/update - body: json - auth: none -} - -headers { - Authorization: Token 4963885504960842e61c6cadb4a9df05647e2c7bf1cfe08ea8cff57ab37058ac -} - -body:json { - { - "id": 20, - "account": "AnotherAccount", - "fork_compatibility": "Not compatible", - "data": { - "age": 31, - "name": "Another name" - } - } -} diff --git a/api-collection/admin/folder.bru b/api-collection/admin/folder.bru deleted file mode 100644 index 7693800..0000000 --- a/api-collection/admin/folder.bru +++ /dev/null @@ -1,3 +0,0 @@ -meta { - name: admin -} diff --git a/api-collection/bruno.json b/api-collection/bruno.json deleted file mode 100644 index 37d4e05..0000000 --- a/api-collection/bruno.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "version": "1", - "name": "central-command", - "type": "collection" -} \ No newline at end of file diff --git a/api-collection/environments/development.bru b/api-collection/environments/development.bru deleted file mode 100644 index 54e0104..0000000 --- a/api-collection/environments/development.bru +++ /dev/null @@ -1,3 +0,0 @@ -vars { - baseUrl: https://dev-api.unitystation.org -} diff --git a/api-collection/environments/local.bru b/api-collection/environments/local.bru deleted file mode 100644 index 2de9da6..0000000 --- a/api-collection/environments/local.bru +++ /dev/null @@ -1,3 +0,0 @@ -vars { - baseUrl: http://localhost:8000 -} diff --git a/src/accounts/api/serializers.py b/src/accounts/api/serializers.py index eba6557..49c7709 100644 --- a/src/accounts/api/serializers.py +++ b/src/accounts/api/serializers.py @@ -98,3 +98,9 @@ def validate(self, data): class EmailSerializer(serializers.Serializer): email = serializers.EmailField() + +class ConnectionChallengeSerializer(serializers.Serializer): + connection_challenge = serializers.CharField(min_length=128, max_length=128) + +class AuthRequestSerializer(ConnectionChallengeSerializer): + fork_compatibility = serializers.CharField(required=False, allow_blank=True, max_length=64) \ No newline at end of file diff --git a/src/accounts/api/urls.py b/src/accounts/api/urls.py index 0cfa98d..718abc1 100644 --- a/src/accounts/api/urls.py +++ b/src/accounts/api/urls.py @@ -2,12 +2,12 @@ from knox import views as knox_views from .views import ( - CheckSHA512ForAccountView, + RedeemSessionView, ConfirmAccountView, LoginWithCredentialsView, LoginWithTokenView, RegisterAccountView, - RegisterSHA512ForAccount, + AuthRequestView, RequestPasswordResetView, RequestVerificationTokenView, ResendAccountConfirmationView, @@ -47,6 +47,6 @@ name="reset-password-token", ), path("reset-password/", RequestPasswordResetView.as_view(), name="reset-password"), - path("register-SHA512-for-account/", RegisterSHA512ForAccount.as_view(), name="register-SHA512-for-account"), - path("check-SHA512-for-account/", CheckSHA512ForAccountView.as_view(), name="check-SHA512-for-account"), + path("auth-request/", AuthRequestView.as_view(), name="auth-request"), + path("redeem-session/", RedeemSessionView.as_view(), name="redeem-session"), ] diff --git a/src/accounts/api/views.py b/src/accounts/api/views.py index a59477c..d43fb9d 100644 --- a/src/accounts/api/views.py +++ b/src/accounts/api/views.py @@ -6,6 +6,7 @@ from uuid import uuid4 from django.conf import settings +from django.core import signing from django.contrib.auth import authenticate from django.core.exceptions import ObjectDoesNotExist, PermissionDenied from django.core.management import BaseCommand @@ -13,7 +14,7 @@ from drf_spectacular.utils import extend_schema from knox.models import AuthToken from knox.views import LoginView as KnoxLoginView -from rest_framework import serializers, status +from rest_framework import status from rest_framework.generics import GenericAPIView from rest_framework.permissions import AllowAny from rest_framework.response import Response @@ -23,8 +24,10 @@ from commons.error_response import ErrorResponse from commons.mail_wrapper import send_email_with_template -from ..models import Account, AccountConfirmation, PasswordResetRequestModel, SHA512Token +from ..models import Account, AccountConfirmation, PasswordResetRequestModel, ConnectionChallenge from .serializers import ( + AuthRequestSerializer, + ConnectionChallengeSerializer, ConfirmAccountSerializer, EmailSerializer, LoginWithCredentialsSerializer, @@ -347,7 +350,7 @@ class ResendAccountConfirmationView(GenericAPIView): permission_classes = (AllowAny,) serializer_class = EmailSerializer - def post(self, request, *args, **kwargs): + def post(self, request): serializer: EmailSerializer = self.serializer_class(data=request.data) if serializer.is_valid(): @@ -374,14 +377,12 @@ def post(self, request, *args, **kwargs): return ErrorResponse(serializer.errors, status.HTTP_400_BAD_REQUEST) -class RegisterSHA512ForAccount(APIView): - class InputSerializer(serializers.Serializer): - sha512_token = serializers.CharField(max_length=128) +class AuthRequestView(APIView): + + serializerClass = AuthRequestSerializer - def post(self, request, *args, **kwargs): + def post(self, request): user: Account = request.user - if not request.auth: - return ErrorResponse("Invalid or missing token.", status.HTTP_401_UNAUTHORIZED) if not user.is_confirmed: return ErrorResponse( @@ -389,19 +390,30 @@ def post(self, request, *args, **kwargs): status.HTTP_403_FORBIDDEN, ) - serializer = self.InputSerializer(data=request.data) + serializer: AuthRequestSerializer = self.serializerClass(data=request.data) if not serializer.is_valid(): return ErrorResponse(serializer.errors, status.HTTP_400_BAD_REQUEST) - SHA512Token.objects.create(account=user, token=serializer.validated_data["sha512_token"]) + ConnectionChallenge.objects.create( + account=user, + connection_challenge=serializer.validated_data["connection_challenge"], + ) + + scope_token_data = { + "unique_identifier": user.unique_identifier, + "fork_compatibility": serializer.validated_data["fork_compatibility"], + } + + signer = signing.TimestampSigner() + scope_token = signer.sign_object(scope_token_data) # Signs + serializes with timestamp return Response( - {"detail": "SHA512 token registered successfully."}, + {"scopeToken": scope_token}, status=status.HTTP_200_OK, ) -class CheckSHA512ForAccountView(APIView): +class RedeemSessionView(APIView): """ Given an account unique_identifier and a SHA512 token, checks if the token is associated with that account. @@ -410,45 +422,39 @@ class CheckSHA512ForAccountView(APIView): """ permission_classes = (AllowAny,) + serializerClass = ConnectionChallengeSerializer - class InputSerializer(serializers.Serializer): - unique_identifier = serializers.CharField(max_length=28) - sha512_token = serializers.CharField(max_length=128) - - def post(self, request, *args, **kwargs): - serializer = self.InputSerializer(data=request.data) + def post(self, request): + serializer: ConnectionChallengeSerializer = self.serializerClass(data=request.data) if not serializer.is_valid(): return ErrorResponse(serializer.errors, status.HTTP_400_BAD_REQUEST) - unique_id = serializer.validated_data["unique_identifier"] - token = serializer.validated_data["sha512_token"] + connection_challenge = serializer.validated_data["connection_challenge"] - try: - account = Account.objects.get(unique_identifier=unique_id) - except Account.DoesNotExist: - return Response({"exists": False}, status=status.HTTP_200_OK) + challenge_object = ConnectionChallenge.objects.filter(connection_challenge=connection_challenge).first() + + if challenge_object: + valid_cutoff = timezone.now() - timedelta(minutes=3) + + if challenge_object.created_at < valid_cutoff: + # Token is older than 3 minutes, delete it + challenge_object.delete() + return ErrorResponse("Token is expired.", status.HTTP_401_UNAUTHORIZED) - valid_cutoff = timezone.now() - timedelta(minutes=3) - matching_token = SHA512Token.objects.filter( - account=account, - token=token, - created_at__gte=valid_cutoff, - ).first() + challenge_object.delete() - if matching_token: - matching_token.delete() return Response( - {"exists": True, "account": PublicAccountDataSerializer(account, context={"request": request}).data}, + {"account": PublicAccountDataSerializer(challenge_object.account, context={"request": request}).data}, status=status.HTTP_200_OK, ) else: - return Response({"exists": False}, status=status.HTTP_200_OK) + return Response("Token is invalid.", status=status.HTTP_401_UNAUTHORIZED) class Command(BaseCommand): - help = "Delete expired SHA512 tokens (older than 3 minutes)" + help = "Delete expired connection challenges (older than 3 minutes)" def handle(self, *args, **kwargs): cutoff = timezone.now() - timedelta(minutes=3) - deleted, _ = SHA512Token.objects.filter(created_at__lt=cutoff).delete() - self.stdout.write(f"Deleted {deleted} expired SHA512 tokens.") + deleted, _ = ConnectionChallenge.objects.filter(created_at__lt=cutoff).delete() + self.stdout.write(f"Deleted {deleted} expired connection challenges.") diff --git a/src/accounts/migrations/0005_sha512token.py b/src/accounts/migrations/0005_sha512token.py deleted file mode 100644 index f2cc5f1..0000000 --- a/src/accounts/migrations/0005_sha512token.py +++ /dev/null @@ -1,24 +0,0 @@ -# Generated by Django 3.2.25 on 2025-08-03 16:41 - -from django.conf import settings -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('accounts', '0004_alter_account_username'), - ] - - operations = [ - migrations.CreateModel( - name='SHA512Token', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('token', models.CharField(max_length=128)), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sha512_tokens', to=settings.AUTH_USER_MODEL)), - ], - ), - ] diff --git a/src/accounts/models.py b/src/accounts/models.py index 1db210b..f47ab1d 100644 --- a/src/accounts/models.py +++ b/src/accounts/models.py @@ -134,13 +134,19 @@ def is_token_valid(self): return (self.created_at + timedelta(minutes=settings.PASS_RESET_TOKEN_TTL)) > timezone.now() -class SHA512Token(models.Model): - token = models.CharField(max_length=128) - account = models.ForeignKey(Account, related_name="sha512_tokens", on_delete=models.CASCADE) +class ConnectionChallenge(models.Model): + connection_challenge = models.CharField( + primary_key=True, + min_length=128, + max_length=128, + help_text="""A random challenge string that is independently calculated by both the client and server. + Traditionally, this is calculated as SHA-512 of a the Server's public key + a random shared secret generated by the client.""" + ) + account = models.ForeignKey(Account, related_name="connection_challenge", on_delete=models.CASCADE) created_at = models.DateTimeField(auto_now_add=True) def __str__(self): - return f"SHA512 token for {self.account} created at {self.created_at}" + return f"Connection challenge for {self.account} created at {self.created_at}" def is_valid(self): return (self.created_at + timedelta(minutes=3)) > timezone.now() diff --git a/src/central_command/settings.py b/src/central_command/settings.py index 8fe78b7..08dce88 100644 --- a/src/central_command/settings.py +++ b/src/central_command/settings.py @@ -258,3 +258,6 @@ ACCOUNT_CONFIRMATION_URL_SUFFIX = os.environ["ACCOUNT_CONFIRMATION_URL_SUFFIX"] ACCOUNT_CONFIRMATION_URL = urljoin(WEBSITE_URL, ACCOUNT_CONFIRMATION_URL_SUFFIX) ACCOUNT_CONFIRMATION_TOKEN_TTL = 24 # hours + +# Scope token settings +SCOPE_TOKEN_TTL = 60 * 60 * 24 * 1 # 1 day in seconds \ No newline at end of file diff --git a/src/persistence/api/permissions.py b/src/persistence/api/permissions.py new file mode 100644 index 0000000..ce0c07e --- /dev/null +++ b/src/persistence/api/permissions.py @@ -0,0 +1,32 @@ +from django.core import signing +from rest_framework.permissions import IsAuthenticated + +from accounts.models import Account +from central_command.settings import SCOPE_TOKEN_TTL + +class TokenOrAccount(IsAuthenticated): + """ + Permission class that checks if the request is using a Character Token, falling back to standard account authentication if not present or invalid. + """ + + def has_permission(self, request, view): + auth_header = request.headers.get("Authorization") + + if not auth_header: + return False + + try: + signer = signing.TimestampSigner() + parsed = signer.unsign_object(auth_header, max_age=SCOPE_TOKEN_TTL) # 1 day in seconds + + request.fork_compatibility = parsed.get("fork_compatibility", None) + + if isinstance(parsed, dict) and "unique_identifier" in parsed: + # If the token is valid, we can assume the user is authenticated + request.user = Account.objects.get(unique_identifier=parsed["unique_identifier"]) + return True + except (signing.SignatureExpired, signing.BadSignature): + pass # ignore it to fall back + + # Fallback to standard account authentication + return super().has_permission(request, view) \ No newline at end of file diff --git a/src/persistence/api/views.py b/src/persistence/api/views.py index 9cdfa68..e84c47d 100644 --- a/src/persistence/api/views.py +++ b/src/persistence/api/views.py @@ -10,6 +10,7 @@ from rest_framework.response import Response from accounts.models import Account +from persistence.api.permissions import TokenOrAccount from ..models import Character from .serializers import ( @@ -23,17 +24,19 @@ class GetCharacterByIdView(GenericAPIView): """ Retrieves a character by its ID. The character must belong to the account of the user. - **Requires Token Authentication.** + **Requires Token Authentication or Scope Token.** """ serializer_class = CharacterSerializer + permission_classes = (TokenOrAccount,) def get_queryset(self): return Character.objects.filter(account__unique_identifier=self.request.user.unique_identifier) # type: ignore def get(self, request, pk): try: - character = Character.objects.get(pk=pk) + # should compatibility be checked here? + character = Character.objects.get(pk=pk, fork_compatibility=request.fork_compatibility) except ObjectDoesNotExist: data = {"error": "No character with this ID could be found!"} return Response(data, status=status.HTTP_404_NOT_FOUND) @@ -52,6 +55,8 @@ class GetCompatibleCharacters(ListAPIView): """ serializer_class = CharacterSerializer + # ScopeToken auth will provide a fork_compatibility of its own, but i see no reason to use it over the query string. + permission_classes = (TokenOrAccount,) def get_queryset(self): """ @@ -62,7 +67,7 @@ def get_queryset(self): - character_sheet_version: The version string of the character sheet. Example usage: - /api/characters/fork_compatibility=Unitystation&character_sheet_version=1.0.0 + /api/characters/?fork_compatibility=Unitystation&character_sheet_version=1.0.0 """ query_serializer = CompatibleCharactersRequestSerializer(data=self.request.query_params) if not query_serializer.is_valid(): @@ -88,6 +93,7 @@ class GetAllCharactersByAccountView(ListAPIView): """ serializer_class = CharacterSerializer + permission_classes = (TokenOrAccount,) def get_queryset(self): """ @@ -106,6 +112,7 @@ class UpdateCharacterView(GenericAPIView): """ serializer_class = UpdateCharacterSerializer + permission_classes = (TokenOrAccount,) queryset = Character.objects.all() def update_character(self, request, pk): @@ -113,6 +120,8 @@ def update_character(self, request, pk): character = Character.objects.get(pk=pk) if character.account != request.user: raise PermissionDenied + if request.fork_compatibility and character.fork_compatibility != request.fork_compatibility: + raise PermissionDenied("This character does not match the server/fork in the token!") except ObjectDoesNotExist: data = {"error": "No character with this ID could be found!"} @@ -143,12 +152,15 @@ class DeleteCharacterView(GenericAPIView): """ serializer_class = CharacterSerializer + permission_classes = (TokenOrAccount,) def delete(self, request, pk): try: character = Character.objects.get(pk=pk) if character.account != request.user: raise PermissionDenied + if request.fork_compatibility and character.fork_compatibility != request.fork_compatibility: + raise PermissionDenied("This character does not match the server/fork in the token!") except ObjectDoesNotExist: data = {"error": "No character with this ID could be found!"} @@ -170,10 +182,13 @@ class CreateCharacterView(GenericAPIView): """ serializer_class = CharacterSerializer + permission_classes = (TokenOrAccount,) def post(self, request): data_with_account = request.data.copy() data_with_account["account"] = request.user.pk + if request.fork_compatibility: + data_with_account["fork_compatibility"] = request.fork_compatibility # Enforce fork from token serializer = self.serializer_class(data=data_with_account) serializer.account = request.user # type: ignore @@ -186,296 +201,4 @@ def post(self, request): data = {"error": "You do not have permission to write this data!"} return Response(data, status=status.HTTP_403_FORBIDDEN) serializer.save() - return Response(serializer.data, status=status.HTTP_201_CREATED) - - -class GenerateForkTokenView(GenericAPIView): - """ - Generates a token for the fork/server and account identifier. - **Requires token in 'X-Character-Token' header.** - """ - - serializer_class = CharacterSerializer - - def post(self, request): - server_id = request.data.get("fork_compatibility") - if not server_id: - return Response( - {"error": "Missing 'fork_compatibility' in request body."}, status=status.HTTP_400_BAD_REQUEST - ) - - user = request.user - if not hasattr(user, "unique_identifier"): - return Response( - {"error": "Authenticated user lacks a unique identifier."}, status=status.HTTP_500_INTERNAL_SERVER_ERROR - ) - - token_data = { - "server_id": server_id, - "uuid": str(user.unique_identifier), - "nonce": secrets.token_hex(8), - } - - # Token expires in 1 day - signer = signing.TimestampSigner() - token = signer.sign_object(token_data) # Signs + serializes with timestamp - - return Response({"token": token}) - - -class CreateCharacterViewToken(GenericAPIView): - """ - Creates a new character based on the token which embeds - the fork/server and account identifier. - - **Requires token in 'X-Character-Token' header.** - """ - - serializer_class = CharacterSerializer - permission_classes = (AllowAny,) - - def generate_token(self, server_id: str) -> str: - data = {"server_id": server_id, "nonce": secrets.token_hex(8), "uuid": str(uuid.uuid4())} - return signing.dumps(data) - - def parse_token(self, token: str) -> dict: - return signing.loads(token) - - def post(self, request): - token = request.headers.get("X-Character-Token") - if not token: - return Response({"error": "Missing X-Character-Token header"}, status=status.HTTP_400_BAD_REQUEST) - - try: - signer = signing.TimestampSigner() - parsed = signer.unsign_object(token, max_age=86400) # 1 day in seconds - except signing.SignatureExpired: - # Token expired - return Response({"error": "Token has expired."}, status=status.HTTP_401_UNAUTHORIZED) - except signing.BadSignature: - # Token invalid - return Response({"error": "Invalid token."}, status=status.HTTP_401_UNAUTHORIZED) - - server_id = parsed.get("server_id") - account_uuid = parsed.get("uuid") - - if not server_id or not account_uuid: - return Response({"error": "Token missing required fields"}, status=status.HTTP_400_BAD_REQUEST) - - # Look up account by UUID - try: - account = Account.objects.get(unique_identifier=account_uuid) - except Account.DoesNotExist: - return Response({"error": "Account not found"}, status=status.HTTP_404_NOT_FOUND) - - data_with_extras = request.data.copy() - data_with_extras["account"] = account.pk - data_with_extras["fork_compatibility"] = server_id # Enforce fork from token - - serializer = self.serializer_class(data=data_with_extras) - serializer.account = account # type: ignore - - try: - serializer.is_valid(raise_exception=True) - except ValidationError as e: - return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST) - except PermissionDenied: - return Response( - {"error": "You do not have permission to write this data!"}, status=status.HTTP_403_FORBIDDEN - ) - - serializer.save() - return Response(serializer.data, status=status.HTTP_201_CREATED) - - -class DeleteCharacterViewToken(GenericAPIView): - """ - Deletes a character by its ID. The character must: - - Belong to the account identified by the token's UUID. - - Match the fork/server ID in the token. - - **Requires 'X-Character-Token' header.** - """ - - serializer_class = CharacterSerializer - permission_classes = (AllowAny,) - - def delete(self, request, pk): - token = request.headers.get("X-Character-Token") - if not token: - return Response({"error": "Missing X-Character-Token header"}, status=status.HTTP_400_BAD_REQUEST) - - try: - signer = signing.TimestampSigner() - parsed = signer.unsign_object(token, max_age=86400) # 1 day in seconds - except signing.SignatureExpired: - # Token expired - return Response({"error": "Token has expired."}, status=status.HTTP_401_UNAUTHORIZED) - except signing.BadSignature: - # Token invalid - return Response({"error": "Invalid token."}, status=status.HTTP_401_UNAUTHORIZED) - - server_id = parsed.get("server_id") - account_uuid = parsed.get("uuid") - - if not server_id or not account_uuid: - return Response({"error": "Token missing required fields"}, status=status.HTTP_400_BAD_REQUEST) - - # Look up account - try: - account = Account.objects.get(unique_identifier=account_uuid) - except Account.DoesNotExist: - return Response({"error": "Account not found"}, status=status.HTTP_404_NOT_FOUND) - - # Attempt to get character - try: - character = Character.objects.get(pk=pk) - except Character.DoesNotExist: - return Response({"error": "No character with this ID could be found!"}, status=status.HTTP_404_NOT_FOUND) - - # Check ownership and fork - if character.account != account: - return Response( - {"error": "You do not have permission to delete this character!"}, status=status.HTTP_403_FORBIDDEN - ) - - if character.fork_compatibility != server_id: - return Response( - {"error": "This character does not match the server/fork in the token!"}, - status=status.HTTP_403_FORBIDDEN, - ) - - character.delete() - return Response({"success": "Character deleted successfully!"}, status=status.HTTP_200_OK) - - -class GetCompatibleCharactersToken(ListAPIView): - """ - Retrieves a list of compatible characters based on the token-provided fork and account. - **Requires 'X-Character-Token' header.** - """ - - serializer_class = CharacterSerializer - permission_classes = (AllowAny,) - - def get_queryset(self): - token = self.request.headers.get("X-Character-Token") - if not token: - raise ValidationError({"token": ["Missing X-Character-Token header"]}) - - try: - signer = signing.TimestampSigner() - parsed = signer.unsign_object(token, max_age=86400) # 1 day in seconds - except signing.SignatureExpired: - # Token expired - return Response({"error": "Token has expired."}, status=status.HTTP_401_UNAUTHORIZED) - except signing.BadSignature: - # Token invalid - return Response({"error": "Invalid token."}, status=status.HTTP_401_UNAUTHORIZED) - - server_id = parsed.get("server_id") - account_uuid = parsed.get("uuid") - if not server_id or not account_uuid: - raise ValidationError({"token": ["Token missing required fields"]}) - - try: - account = Account.objects.get(unique_identifier=account_uuid) - except Account.DoesNotExist: - raise ValidationError({"account": ["Account not found"]}) - - # Add fork_compatibility from the token and character_sheet_version from query - query_data = { - "character_sheet_version": self.request.query_params.get("character_sheet_version"), - "fork_compatibility": server_id, - } - query_serializer = CompatibleCharactersRequestSerializer(data=query_data) - query_serializer.is_valid(raise_exception=True) - - character_sheet_version = query_serializer.validated_data["character_sheet_version"] - - return Character.objects.filter( - account=account, - fork_compatibility=server_id, - character_sheet_version=character_sheet_version, - ) - - -class UpdateCharacterViewToken(GenericAPIView): - """ - Updates a character by its ID using token-based authentication. - If it does not exist, creates it. - **Requires 'X-Character-Token' header.** - """ - - serializer_class = UpdateCharacterSerializer - queryset = Character.objects.all() - permission_classes = (AllowAny,) - - def update_or_create_character(self, request, pk): - # Get token - token = request.headers.get("X-Character-Token") - if not token: - return Response({"error": "Missing X-Character-Token header"}, status=status.HTTP_400_BAD_REQUEST) - - try: - signer = signing.TimestampSigner() - parsed = signer.unsign_object(token, max_age=86400) # 1 day in seconds - except signing.SignatureExpired: - # Token expired - return Response({"error": "Token has expired."}, status=status.HTTP_401_UNAUTHORIZED) - except signing.BadSignature: - # Token invalid - return Response({"error": "Invalid token."}, status=status.HTTP_401_UNAUTHORIZED) - - server_id = parsed.get("server_id") - account_uuid = parsed.get("uuid") - if not server_id or not account_uuid: - return Response({"error": "Token missing required fields"}, status=status.HTTP_400_BAD_REQUEST) - - # Get account - try: - account = Account.objects.get(unique_identifier=account_uuid) - except Account.DoesNotExist: - return Response({"error": "Account not found"}, status=status.HTTP_404_NOT_FOUND) - - # Try to get character, otherwise create a new one - try: - character = Character.objects.get(pk=pk) - is_new = False - except Character.DoesNotExist: - character = None - is_new = True - - # If updating, check ownership and fork compatibility - if not is_new and character is not None: - if character.account != account: - return Response( - {"error": "You do not have permission to edit this character!"}, status=status.HTTP_403_FORBIDDEN - ) - if character.fork_compatibility != server_id: - return Response( - {"error": "This character does not match the server/fork in the token!"}, - status=status.HTTP_403_FORBIDDEN, - ) - - # Force account and fork_compatibility from token (both on create and update) - incoming_data = request.data.copy() - incoming_data["account"] = account.pk - incoming_data["fork_compatibility"] = server_id - - if is_new: - serializer = self.get_serializer(data=incoming_data) - else: - serializer = self.get_serializer(character, data=incoming_data, partial=True) - - if serializer.is_valid(): - serializer.save() - return Response(serializer.data, status=status.HTTP_201_CREATED if is_new else status.HTTP_200_OK) - else: - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - - def patch(self, request, pk): - return self.update_or_create_character(request, pk) - - def put(self, request, pk): - return self.update_or_create_character(request, pk) + return Response(serializer.data, status=status.HTTP_201_CREATED) \ No newline at end of file From d4420406613d27c3cb7871f8eb9cfbc347983d7c Mon Sep 17 00:00:00 2001 From: craftxbox Date: Sun, 10 Aug 2025 23:33:06 -0230 Subject: [PATCH 02/13] remove extraneous api paths & created migration --- .../migrations/0005_connectionchallenge.py | 23 +++++++++++++++++++ src/accounts/models.py | 1 - src/persistence/api/urls.py | 18 --------------- 3 files changed, 23 insertions(+), 19 deletions(-) create mode 100644 src/accounts/migrations/0005_connectionchallenge.py diff --git a/src/accounts/migrations/0005_connectionchallenge.py b/src/accounts/migrations/0005_connectionchallenge.py new file mode 100644 index 0000000..447880a --- /dev/null +++ b/src/accounts/migrations/0005_connectionchallenge.py @@ -0,0 +1,23 @@ +# Generated by Django 3.2.25 on 2025-08-11 02:02 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0004_alter_account_username'), + ] + + operations = [ + migrations.CreateModel( + name='ConnectionChallenge', + fields=[ + ('connection_challenge', models.CharField(help_text="A random challenge string that is independently calculated by both the client and server.\n Traditionally, this is calculated as SHA-512 of a the Server's public key + a random shared secret generated by the client.", max_length=128, primary_key=True, serialize=False)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='connection_challenge', to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/src/accounts/models.py b/src/accounts/models.py index f47ab1d..7b8610b 100644 --- a/src/accounts/models.py +++ b/src/accounts/models.py @@ -137,7 +137,6 @@ def is_token_valid(self): class ConnectionChallenge(models.Model): connection_challenge = models.CharField( primary_key=True, - min_length=128, max_length=128, help_text="""A random challenge string that is independently calculated by both the client and server. Traditionally, this is calculated as SHA-512 of a the Server's public key + a random shared secret generated by the client.""" diff --git a/src/persistence/api/urls.py b/src/persistence/api/urls.py index a2c3692..b492d4a 100644 --- a/src/persistence/api/urls.py +++ b/src/persistence/api/urls.py @@ -2,16 +2,11 @@ from .views import ( CreateCharacterView, - CreateCharacterViewToken, DeleteCharacterView, - DeleteCharacterViewToken, - GenerateForkTokenView, GetAllCharactersByAccountView, GetCharacterByIdView, GetCompatibleCharacters, - GetCompatibleCharactersToken, UpdateCharacterView, - UpdateCharacterViewToken, ) app_name = "persistence" @@ -23,17 +18,4 @@ path("characters/compatible", GetCompatibleCharacters.as_view(), name="characters-compatible"), path("characters//update", UpdateCharacterView.as_view(), name="characters-patch"), path("characters//delete", DeleteCharacterView.as_view(), name="characters-delete"), - path( - "characters//updateToken", UpdateCharacterViewToken.as_view(), name="characters-patch-token" - ), # PutAccountsCharacterByIDByCharactersToken - path( - "characters/createToken", CreateCharacterViewToken.as_view(), name="characters-create-token" - ), # PostMakeAccountsCharacterByCharactersToken - path( - "characters/compatibleToken", GetCompatibleCharactersToken.as_view(), name="characters-compatible-token" - ), # GetCharactersByCharacterSheetToken - path( - "characters//deleteToken", DeleteCharacterViewToken.as_view(), name="characters-delete-token" - ), # DeleteAccountsCharacterByIDByCharactersToken - path("characters/GenForkToken", GenerateForkTokenView.as_view(), name="Gen-Fork-token"), ] From 7c70d52d0350538fed2ee27c6bdb22e2d44cafbc Mon Sep 17 00:00:00 2001 From: craftxbox Date: Sun, 10 Aug 2025 23:39:44 -0230 Subject: [PATCH 03/13] fixup docs and remove extraneous imports --- src/accounts/api/views.py | 11 +++++++++-- src/persistence/api/views.py | 16 +++++----------- 2 files changed, 14 insertions(+), 13 deletions(-) diff --git a/src/accounts/api/views.py b/src/accounts/api/views.py index d43fb9d..86c6131 100644 --- a/src/accounts/api/views.py +++ b/src/accounts/api/views.py @@ -378,6 +378,12 @@ def post(self, request): class AuthRequestView(APIView): + """ + Given a connection challenge and fork compatibility, registers a connection challenge + for the authenticated user and returns a signed scope token. + + **Requires Token authentication** + """ serializerClass = AuthRequestSerializer @@ -415,9 +421,10 @@ def post(self, request): class RedeemSessionView(APIView): """ - Given an account unique_identifier and a SHA512 token, - checks if the token is associated with that account. + Given an account unique_identifier and a connection challenge, + checks if the token is associated with that account, returning it if so. Deletes the token after checking. + **Public endpoint** """ diff --git a/src/persistence/api/views.py b/src/persistence/api/views.py index e84c47d..42c3a67 100644 --- a/src/persistence/api/views.py +++ b/src/persistence/api/views.py @@ -1,15 +1,9 @@ -import secrets -import uuid - -from django.core import signing from django.core.exceptions import ObjectDoesNotExist, PermissionDenied from rest_framework import status from rest_framework.exceptions import ValidationError from rest_framework.generics import GenericAPIView, ListAPIView -from rest_framework.permissions import AllowAny from rest_framework.response import Response -from accounts.models import Account from persistence.api.permissions import TokenOrAccount from ..models import Character @@ -51,7 +45,7 @@ class GetCompatibleCharacters(ListAPIView): """ Retrieves a list of compatible characters for the user's account. - **Requires Token Authentication.** + **Requires Token Authentication or Scope Token.** """ serializer_class = CharacterSerializer @@ -89,7 +83,7 @@ class GetAllCharactersByAccountView(ListAPIView): """ Retrieves a list of all characters of an account, disregarding compatibility. - **Requires Token Authentication.** + **Requires Token Authentication or Scope Token.** """ serializer_class = CharacterSerializer @@ -108,7 +102,7 @@ class UpdateCharacterView(GenericAPIView): """ Updates a character by its ID. The character must belong to the account of the user. - **Requires Token Authentication.** + **Requires Token Authentication or Scope Token.** """ serializer_class = UpdateCharacterSerializer @@ -148,7 +142,7 @@ class DeleteCharacterView(GenericAPIView): """ Deletes a character by its ID. The character must belong to the account of the user. - **Requires Token Authentication.** + **Requires Token Authentication or Scope Token.** """ serializer_class = CharacterSerializer @@ -178,7 +172,7 @@ class CreateCharacterView(GenericAPIView): """ Creates a new character. - **Requires Token Authentication.** + **Requires Token Authentication or Scope Token.** """ serializer_class = CharacterSerializer From 8c27dd0437afcbeaddb229c2efee4d3face2be39 Mon Sep 17 00:00:00 2001 From: craftxbox Date: Sun, 10 Aug 2025 23:46:41 -0230 Subject: [PATCH 04/13] moves the command --- src/accounts/api/views.py | 10 ---------- src/accounts/management/__init__.py | 0 src/accounts/management/commands/__init__.py | 0 .../commands/clear_expired_challenges.py | 18 ++++++++++++++++++ 4 files changed, 18 insertions(+), 10 deletions(-) create mode 100644 src/accounts/management/__init__.py create mode 100644 src/accounts/management/commands/__init__.py create mode 100644 src/accounts/management/commands/clear_expired_challenges.py diff --git a/src/accounts/api/views.py b/src/accounts/api/views.py index 86c6131..2918081 100644 --- a/src/accounts/api/views.py +++ b/src/accounts/api/views.py @@ -9,7 +9,6 @@ from django.core import signing from django.contrib.auth import authenticate from django.core.exceptions import ObjectDoesNotExist, PermissionDenied -from django.core.management import BaseCommand from django.utils import timezone from drf_spectacular.utils import extend_schema from knox.models import AuthToken @@ -456,12 +455,3 @@ def post(self, request): ) else: return Response("Token is invalid.", status=status.HTTP_401_UNAUTHORIZED) - - -class Command(BaseCommand): - help = "Delete expired connection challenges (older than 3 minutes)" - - def handle(self, *args, **kwargs): - cutoff = timezone.now() - timedelta(minutes=3) - deleted, _ = ConnectionChallenge.objects.filter(created_at__lt=cutoff).delete() - self.stdout.write(f"Deleted {deleted} expired connection challenges.") diff --git a/src/accounts/management/__init__.py b/src/accounts/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/accounts/management/commands/__init__.py b/src/accounts/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/accounts/management/commands/clear_expired_challenges.py b/src/accounts/management/commands/clear_expired_challenges.py new file mode 100644 index 0000000..858c6d6 --- /dev/null +++ b/src/accounts/management/commands/clear_expired_challenges.py @@ -0,0 +1,18 @@ +import logging + +from datetime import timedelta +from django.utils import timezone + +from django.core.management.base import BaseCommand + +from accounts.models import ConnectionChallenge + +logger = logging.getLogger(__name__) + +class Command(BaseCommand): + help = "Delete expired connection challenges (older than 3 minutes)" + + def handle(self, *args, **kwargs): + cutoff = timezone.now() - timedelta(minutes=3) + deleted, _ = ConnectionChallenge.objects.filter(created_at__lt=cutoff).delete() + logger.info(f"Deleted {deleted} expired connection challenges.") \ No newline at end of file From 6f2ac68375a51bdf449e450e4c3596c392609226 Mon Sep 17 00:00:00 2001 From: craftxbox Date: Mon, 11 Aug 2025 00:37:15 -0230 Subject: [PATCH 05/13] prevent challenge reuse & fix some errors when fork_compatibility does not exist on a request --- src/accounts/api/serializers.py | 3 ++- src/accounts/api/views.py | 8 +++++++- src/accounts/models.py | 2 +- src/persistence/api/views.py | 15 +++++++++++---- 4 files changed, 21 insertions(+), 7 deletions(-) diff --git a/src/accounts/api/serializers.py b/src/accounts/api/serializers.py index 49c7709..67aec33 100644 --- a/src/accounts/api/serializers.py +++ b/src/accounts/api/serializers.py @@ -103,4 +103,5 @@ class ConnectionChallengeSerializer(serializers.Serializer): connection_challenge = serializers.CharField(min_length=128, max_length=128) class AuthRequestSerializer(ConnectionChallengeSerializer): - fork_compatibility = serializers.CharField(required=False, allow_blank=True, max_length=64) \ No newline at end of file + #previously used to be allow_blank but blank seems to get interpreted as not being present, which causes + fork_compatibility = serializers.CharField(max_length=64) \ No newline at end of file diff --git a/src/accounts/api/views.py b/src/accounts/api/views.py index 2918081..7a6d99e 100644 --- a/src/accounts/api/views.py +++ b/src/accounts/api/views.py @@ -399,9 +399,15 @@ def post(self, request): if not serializer.is_valid(): return ErrorResponse(serializer.errors, status.HTTP_400_BAD_REQUEST) + if ConnectionChallenge.objects.filter(connection_challenge=serializer.validated_data["connection_challenge"]).count() > 0: + return ErrorResponse( + "Connection challenge reuse is prohibited.", + status.HTTP_400_BAD_REQUEST, + ) + ConnectionChallenge.objects.create( account=user, - connection_challenge=serializer.validated_data["connection_challenge"], + connection_challenge=serializer.validated_data["connection_challenge"], ) scope_token_data = { diff --git a/src/accounts/models.py b/src/accounts/models.py index 7b8610b..e550c16 100644 --- a/src/accounts/models.py +++ b/src/accounts/models.py @@ -139,7 +139,7 @@ class ConnectionChallenge(models.Model): primary_key=True, max_length=128, help_text="""A random challenge string that is independently calculated by both the client and server. - Traditionally, this is calculated as SHA-512 of a the Server's public key + a random shared secret generated by the client.""" + Traditionally, this is calculated as SHA-512 of the Server's public key + a random shared secret generated by the client.""" ) account = models.ForeignKey(Account, related_name="connection_challenge", on_delete=models.CASCADE) created_at = models.DateTimeField(auto_now_add=True) diff --git a/src/persistence/api/views.py b/src/persistence/api/views.py index 42c3a67..cb5440f 100644 --- a/src/persistence/api/views.py +++ b/src/persistence/api/views.py @@ -30,7 +30,14 @@ def get_queryset(self): def get(self, request, pk): try: # should compatibility be checked here? - character = Character.objects.get(pk=pk, fork_compatibility=request.fork_compatibility) + query = { + "pk": pk, + } + + if hasattr(request, "fork_compatibility"): + query["fork_compatibility"] = request.fork_compatibility + + character = Character.objects.get(**query) except ObjectDoesNotExist: data = {"error": "No character with this ID could be found!"} return Response(data, status=status.HTTP_404_NOT_FOUND) @@ -114,7 +121,7 @@ def update_character(self, request, pk): character = Character.objects.get(pk=pk) if character.account != request.user: raise PermissionDenied - if request.fork_compatibility and character.fork_compatibility != request.fork_compatibility: + if hasattr(request, "fork_compatibility") and character.fork_compatibility != request.fork_compatibility: raise PermissionDenied("This character does not match the server/fork in the token!") except ObjectDoesNotExist: @@ -153,7 +160,7 @@ def delete(self, request, pk): character = Character.objects.get(pk=pk) if character.account != request.user: raise PermissionDenied - if request.fork_compatibility and character.fork_compatibility != request.fork_compatibility: + if hasattr(request, "fork_compatibility") and character.fork_compatibility != request.fork_compatibility: raise PermissionDenied("This character does not match the server/fork in the token!") except ObjectDoesNotExist: @@ -181,7 +188,7 @@ class CreateCharacterView(GenericAPIView): def post(self, request): data_with_account = request.data.copy() data_with_account["account"] = request.user.pk - if request.fork_compatibility: + if hasattr(request, "fork_compatibility"): data_with_account["fork_compatibility"] = request.fork_compatibility # Enforce fork from token serializer = self.serializer_class(data=data_with_account) From 51a6665ba7a4754f38260275693a878d2128052d Mon Sep 17 00:00:00 2001 From: Gilles <43683714+corp-0@users.noreply.github.com> Date: Sat, 1 Nov 2025 03:26:19 -0300 Subject: [PATCH 06/13] feat: port serverlist into Central Command (#102) * fix: run pre-commit checks from inside uv venv * feat: ported current serverlist logic to cc * feat: include rate limiting to avoid being taken down by funny users * chore: baby serverlist migrations * feat: add API tests for BabyServer and update permissions for ListBabyServersView * feat: improved test coverage --- .github/workflows/main.yml | 2 +- .pre-commit-config.yaml | 12 +- dev-compose.yml | 8 +- docker-compose.yml | 6 + pyproject.toml | 1 + src/accounts/api/serializers.py | 2 +- src/accounts/api/views.py | 2 +- src/baby_serverlist/__init__.py | 0 src/baby_serverlist/admin.py | 10 + src/baby_serverlist/api/serializers.py | 31 +++ src/baby_serverlist/api/urls.py | 18 ++ src/baby_serverlist/api/views.py | 154 +++++++++++++++ src/baby_serverlist/apps.py | 6 + .../migrations/0001_initial.py | 27 +++ src/baby_serverlist/migrations/__init__.py | 0 src/baby_serverlist/models.py | 53 +++++ src/central_command/settings.py | 31 +++ src/central_command/urls.py | 1 + src/commons/cache.py | 57 ++++++ .../endpoints/test_confirm_account.py | 38 ++++ .../endpoints/test_login_credentials.py | 2 + .../accounts/endpoints/test_login_token.py | 2 + src/tests/accounts/endpoints/test_register.py | 16 ++ .../accounts/endpoints/test_reset_password.py | 14 ++ .../test_server_verification_token.py | 4 + src/tests/baby_serverlist/__init__.py | 1 + src/tests/baby_serverlist/test_api.py | 186 ++++++++++++++++++ uv.lock | 11 ++ 28 files changed, 684 insertions(+), 11 deletions(-) create mode 100644 src/baby_serverlist/__init__.py create mode 100644 src/baby_serverlist/admin.py create mode 100644 src/baby_serverlist/api/serializers.py create mode 100644 src/baby_serverlist/api/urls.py create mode 100644 src/baby_serverlist/api/views.py create mode 100644 src/baby_serverlist/apps.py create mode 100644 src/baby_serverlist/migrations/0001_initial.py create mode 100644 src/baby_serverlist/migrations/__init__.py create mode 100644 src/baby_serverlist/models.py create mode 100644 src/commons/cache.py create mode 100644 src/tests/accounts/endpoints/test_confirm_account.py create mode 100644 src/tests/baby_serverlist/__init__.py create mode 100644 src/tests/baby_serverlist/test_api.py diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 3fc2f5f..d21becf 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -68,7 +68,7 @@ jobs: cd src uv run manage.py makemigrations --check uv run manage.py migrate - uv run manage.py test tests/ + uv run manage.py test docker: needs: [ lint, unit_test ] diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 4d585ab..261d776 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -13,22 +13,20 @@ repos: args: ["--allow-multiple-documents"] - id: debug-statements - id: trailing-whitespace - exclude: >- - ^.*.md$ + exclude: ^.*.md$ - repo: https://github.com/astral-sh/ruff-pre-commit rev: v0.7.0 hooks: - id: ruff - args: [ --fix ] + args: [--fix] - id: ruff-format - # local mypy because of stub dependencies - repo: local hooks: - id: typecheck - name: Typecheck - entry: mypy . - types: [python] + name: Typecheck (uv) + entry: uv run mypy language: system pass_filenames: false + args: ["."] diff --git a/dev-compose.yml b/dev-compose.yml index 03b6a8b..123bcbf 100644 --- a/dev-compose.yml +++ b/dev-compose.yml @@ -10,9 +10,15 @@ services: ports: - "5432:5432" + cache: + image: memcached:latest + ports: + - "11211:11211" + web: depends_on: - - db + - db + - cache build: . env_file: ./.env ports: diff --git a/docker-compose.yml b/docker-compose.yml index cdbacbe..0e7639c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,7 +8,13 @@ services: volumes: - db-data:/var/lib/postgresql/data + cache: + image: memcached:latest + web: + depends_on: + - db + - cache image: unitystation/central-command:latest environment: - DEBUG=0 diff --git a/pyproject.toml b/pyproject.toml index 4f40742..a5a46d0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,6 +13,7 @@ dependencies = [ "psycopg2-binary~=2.9.9", "python-dotenv~=0.19.2", "whitenoise~=6.6.0", + "pymemcache>=4.0,<5.0", ] [build-system] diff --git a/src/accounts/api/serializers.py b/src/accounts/api/serializers.py index eba6557..ce42bd7 100644 --- a/src/accounts/api/serializers.py +++ b/src/accounts/api/serializers.py @@ -93,7 +93,7 @@ def validate(self, data): if not account_confirmation.is_token_valid(): raise serializers.ValidationError({"token": "Token is invalid or expired."}) - return {"token": data["token"]} + return {"token": data["token"], "account_confirmation": account_confirmation} class EmailSerializer(serializers.Serializer): diff --git a/src/accounts/api/views.py b/src/accounts/api/views.py index 21aedc7..9a0cfd0 100644 --- a/src/accounts/api/views.py +++ b/src/accounts/api/views.py @@ -324,7 +324,7 @@ def post(self, request): if not serializer.is_valid(): return ErrorResponse(serializer.errors, status.HTTP_400_BAD_REQUEST) - account_confirmation = AccountConfirmation.objects.get(token=serializer.validated_data["token"]) + account_confirmation: AccountConfirmation = serializer.validated_data["account_confirmation"] account = account_confirmation.account account.is_confirmed = True diff --git a/src/baby_serverlist/__init__.py b/src/baby_serverlist/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/baby_serverlist/admin.py b/src/baby_serverlist/admin.py new file mode 100644 index 0000000..fa52808 --- /dev/null +++ b/src/baby_serverlist/admin.py @@ -0,0 +1,10 @@ +from django.contrib import admin + +from .models import BabyServer + + +@admin.register(BabyServer) +class BabyServerAdmin(admin.ModelAdmin): + list_display = ("id", "owner", "whitelisted", "serverlist_token") + search_fields = ("id", "owner__email", "owner__unique_identifier") + list_filter = ("whitelisted",) diff --git a/src/baby_serverlist/api/serializers.py b/src/baby_serverlist/api/serializers.py new file mode 100644 index 0000000..d381fa3 --- /dev/null +++ b/src/baby_serverlist/api/serializers.py @@ -0,0 +1,31 @@ +from rest_framework import serializers + + +class ServerStatusSerializer(serializers.Serializer): + ServerToken = serializers.CharField() + Passworded = serializers.BooleanField() + ServerName = serializers.CharField() + ForkName = serializers.CharField() + BuildVersion = serializers.IntegerField() + CurrentMap = serializers.CharField() + GameMode = serializers.CharField() + IngameTime = serializers.CharField() + RoundTime = serializers.CharField() + PlayerCount = serializers.IntegerField() + PlayerCountMax = serializers.IntegerField() + ServerIP = serializers.CharField() + ServerPort = serializers.IntegerField() + WinDownload = serializers.CharField() + OSXDownload = serializers.CharField() + LinuxDownload = serializers.CharField() + fps = serializers.IntegerField() + GoodFileVersion = serializers.CharField() + + +class ActiveServersSerializer(serializers.Serializer): + CashDateTime = serializers.DateTimeField() + servers = ServerStatusSerializer(many=True) + + +class RegenerateServerlistTokenSerializer(serializers.Serializer): + server_id = serializers.UUIDField() diff --git a/src/baby_serverlist/api/urls.py b/src/baby_serverlist/api/urls.py new file mode 100644 index 0000000..6ebb9d7 --- /dev/null +++ b/src/baby_serverlist/api/urls.py @@ -0,0 +1,18 @@ +from django.urls import path + +from .views import ( + CreateBabyServerView, + ListBabyServersView, + ListOwnedBabyServersView, + PostServerStatusView, + RegenerateServerlistTokenView, +) + +app_name = "baby_serverlist" +urlpatterns = [ + path("status/", PostServerStatusView.as_view(), name="report-status"), + path("servers/create/", CreateBabyServerView.as_view(), name="create"), + path("servers/owned/", ListOwnedBabyServersView.as_view(), name="list-owned"), + path("servers/", ListBabyServersView.as_view(), name="list"), + path("servers/regenerate-token/", RegenerateServerlistTokenView.as_view(), name="regenerate-token"), +] diff --git a/src/baby_serverlist/api/views.py b/src/baby_serverlist/api/views.py new file mode 100644 index 0000000..4b054a6 --- /dev/null +++ b/src/baby_serverlist/api/views.py @@ -0,0 +1,154 @@ +import logging + +from datetime import UTC, datetime +from typing import cast + +from django.core import signing +from rest_framework import status +from rest_framework.generics import GenericAPIView, ListAPIView +from rest_framework.permissions import AllowAny +from rest_framework.response import Response + +from accounts.models import Account +from baby_serverlist.models import SERVERLIST_TOKEN_SALT, BabyServer +from commons.cache import ( + get_many_baby_server_statuses, + set_baby_server_heartbeat, + set_baby_server_status, +) +from commons.error_response import ErrorResponse + +from .serializers import RegenerateServerlistTokenSerializer, ServerStatusSerializer + +logger = logging.getLogger(__name__) + + +class PostServerStatusView(GenericAPIView): + """Accepts signed status payloads from baby servers and stores the latest state in cache.""" + + serializer_class = ServerStatusSerializer + permission_classes = (AllowAny,) + + def post(self, request, *args, **kwargs): + serializer = self.get_serializer(data=request.data) + + if not serializer.is_valid(): + return ErrorResponse(serializer.errors, status.HTTP_400_BAD_REQUEST) + + status_payload = serializer.validated_data + server_token = status_payload.get("ServerToken") + + try: + payload = signing.loads(server_token, salt=SERVERLIST_TOKEN_SALT) + except signing.BadSignature: + return ErrorResponse("Invalid or expired token", status.HTTP_400_BAD_REQUEST) + + try: + baby_server = BabyServer.objects.get(id=payload.get("server_id"), serverlist_token=server_token) + except BabyServer.DoesNotExist: + return ErrorResponse("Invalid or expired token", status.HTTP_400_BAD_REQUEST) + + server_id = str(baby_server.id) + status_without_token = {key: value for key, value in status_payload.items() if key != "ServerToken"} + + set_baby_server_status(server_id, status_without_token) + set_baby_server_heartbeat(server_id, datetime.now(tz=UTC).isoformat()) + + logger.debug("Received server status update for server %s: %s", baby_server.id, status_without_token) + + return Response(status=status.HTTP_200_OK) + + +class CreateBabyServerView(GenericAPIView): + """Creates a new baby server for the authenticated user and returns the freshly minted token.""" + + queryset = BabyServer.objects.all() + + def post(self, request, *args, **kwargs): + user = cast(Account, request.user) + + baby_server = BabyServer.objects.create(owner=user) + + return Response( + { + "id": str(baby_server.id), + "serverlist_token": baby_server.serverlist_token, + "whitelisted": baby_server.whitelisted, + }, + status=status.HTTP_201_CREATED, + ) + + +class ListOwnedBabyServersView(ListAPIView): + """Lists the caller's baby servers with a derived `live` flag based on recent heartbeats.""" + + def get_queryset(self): + user = cast(Account, self.request.user) + return BabyServer.objects.filter(owner=user).only("id", "whitelisted") + + def list(self, request, *args, **kwargs): + queryset = self.get_queryset() + + data = [ + { + "id": str(server.id), + "whitelisted": server.whitelisted, + "live": server.is_live(), + } + for server in queryset + ] + return Response(data, status=status.HTTP_200_OK) + + +class ListBabyServersView(ListAPIView): + """Return cached status payloads for all baby servers that have reported recently.""" + + permission_classes = (AllowAny,) + + def list(self, request, *args, **kwargs): + servers = BabyServer.objects.filter(whitelisted=True) + server_ids = [str(server.id) for server in servers] + status_map = get_many_baby_server_statuses(server_ids) + + data = [ + status_map[server_id] for server_id in sorted(status_map.keys()) if isinstance(status_map[server_id], dict) + ] + + return Response({"servers": data}, status=status.HTTP_200_OK) + + +class RegenerateServerlistTokenView(GenericAPIView): + """Regenerates a server's signed token after validating ownership.""" + + serializer_class = RegenerateServerlistTokenSerializer + queryset = BabyServer.objects.all() + + def post(self, request, *args, **kwargs): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + + server_id = serializer.validated_data["server_id"] + + try: + baby_server: BabyServer = BabyServer.objects.get(id=server_id) + except BabyServer.DoesNotExist: + return ErrorResponse("Baby server not found", status.HTTP_404_NOT_FOUND) + + user = cast(Account, request.user) + + if baby_server.owner != user: + return ErrorResponse( + "You do not have permission to modify this baby server", + status.HTTP_403_FORBIDDEN, + ) + + baby_server.serverlist_token = baby_server.generate_serverlist_token() + baby_server.save(update_fields=["serverlist_token"]) + + return Response( + { + "id": str(baby_server.id), + "serverlist_token": baby_server.serverlist_token, + }, + status=status.HTTP_200_OK, + ) diff --git a/src/baby_serverlist/apps.py b/src/baby_serverlist/apps.py new file mode 100644 index 0000000..5a3579e --- /dev/null +++ b/src/baby_serverlist/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class BabyServerlistConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "baby_serverlist" diff --git a/src/baby_serverlist/migrations/0001_initial.py b/src/baby_serverlist/migrations/0001_initial.py new file mode 100644 index 0000000..5766db9 --- /dev/null +++ b/src/baby_serverlist/migrations/0001_initial.py @@ -0,0 +1,27 @@ +# Generated by Django 3.2.25 on 2025-11-01 05:11 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='BabyServer', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('serverlist_token', models.TextField(editable=False, unique=True)), + ('whitelisted', models.BooleanField(default=False)), + ('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='baby_servers', to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/src/baby_serverlist/migrations/__init__.py b/src/baby_serverlist/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/baby_serverlist/models.py b/src/baby_serverlist/models.py new file mode 100644 index 0000000..1e893a9 --- /dev/null +++ b/src/baby_serverlist/models.py @@ -0,0 +1,53 @@ +from datetime import UTC, datetime, timedelta +from secrets import token_urlsafe +from uuid import uuid4 + +from django.core import signing +from django.db import models + +from accounts.models import Account +from commons.cache import get_baby_server_heartbeat + +SERVERLIST_TOKEN_SALT = "baby_serverlist.serverlist_token" + + +class BabyServer(models.Model): + id = models.UUIDField(primary_key=True, default=uuid4, editable=False) + serverlist_token = models.TextField(unique=True, editable=False) + whitelisted = models.BooleanField(default=False) + owner = models.ForeignKey( + Account, + on_delete=models.CASCADE, + related_name="baby_servers", + ) + objects = models.Manager() + + def __str__(self) -> str: + return f"BabyServer(id={self.id}, owner={self.owner.unique_identifier})" + + def save(self, *args, **kwargs): + if not self.serverlist_token: + self.serverlist_token = self.generate_serverlist_token() + super().save(*args, **kwargs) + + def generate_serverlist_token(self) -> str: + """Create a signed token that uniquely identifies this server and can be validated by clients.""" + payload = { + "server_id": str(self.id), + "owner_id": str(self.owner.unique_identifier), + "nonce": token_urlsafe(16), + } + return signing.dumps(payload, salt=SERVERLIST_TOKEN_SALT) + + def is_live(self) -> bool: + """Return True when the server has reported within the last 12 seconds.""" + heartbeat_iso = get_baby_server_heartbeat(str(self.id)) + if not heartbeat_iso: + return False + try: + heartbeat_time = datetime.fromisoformat(heartbeat_iso) + except ValueError: + return False + if heartbeat_time.tzinfo is None: + heartbeat_time = heartbeat_time.replace(tzinfo=UTC) + return datetime.now(tz=UTC) - heartbeat_time <= timedelta(seconds=12) diff --git a/src/central_command/settings.py b/src/central_command/settings.py index 8fe78b7..91431f4 100644 --- a/src/central_command/settings.py +++ b/src/central_command/settings.py @@ -51,6 +51,7 @@ "post_office", "accounts", "persistence", + "baby_serverlist", "drf_spectacular", ] @@ -154,6 +155,28 @@ } } +MEMCACHED_HOST = os.environ.get("MEMCACHED_HOST", "cache") +MEMCACHED_PORT = os.environ.get("MEMCACHED_PORT", "11211") + +CACHES = { + "default": { + "BACKEND": "django.core.cache.backends.memcached.PyMemcacheCache", + "LOCATION": f"{MEMCACHED_HOST}:{MEMCACHED_PORT}", + } +} + + +if "test" in sys.argv: + DATABASES["default"] = { + "ENGINE": "django.db.backends.sqlite3", + "NAME": ":memory:", + } + + CACHES["default"] = { + "BACKEND": "django.core.cache.backends.locmem.LocMemCache", + "LOCATION": "tests", + } + REST_FRAMEWORK = { "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema", "DEFAULT_AUTHENTICATION_CLASSES": ["knox.auth.TokenAuthentication"], @@ -161,6 +184,14 @@ "DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination", "PAGE_SIZE": 10, "EXCEPTION_HANDLER": "commons.error_response.custom_exception_handler", + "DEFAULT_THROTTLE_CLASSES": [ + "rest_framework.throttling.AnonRateThrottle", + "rest_framework.throttling.UserRateThrottle", + ], + "DEFAULT_THROTTLE_RATES": { + "anon": "60/minute", + "user": "120/minute", + }, } SPECTACULAR_SETTINGS = { diff --git a/src/central_command/urls.py b/src/central_command/urls.py index 454b44f..fd3c277 100644 --- a/src/central_command/urls.py +++ b/src/central_command/urls.py @@ -25,4 +25,5 @@ # API REST FRAMEWORK path("accounts/", include("accounts.api.urls", "Accounts API")), path("persistence/", include("persistence.api.urls")), + path("baby-serverlist/", include("baby_serverlist.api.urls")), ] diff --git a/src/commons/cache.py b/src/commons/cache.py new file mode 100644 index 0000000..31e2075 --- /dev/null +++ b/src/commons/cache.py @@ -0,0 +1,57 @@ +from collections.abc import Iterable +from typing import Any + +from django.core.cache import cache + +SERVER_STATUS_KEY_PREFIX = "baby_server_status:" +SERVER_HEARTBEAT_KEY_PREFIX = "baby_server_heartbeat:" + + +def _status_key(server_id: str) -> str: + """Build the cache key used to store a server's status payload.""" + return f"{SERVER_STATUS_KEY_PREFIX}{server_id}" + + +def _heartbeat_key(server_id: str) -> str: + """Build the cache key used to store a server's last heartbeat timestamp.""" + return f"{SERVER_HEARTBEAT_KEY_PREFIX}{server_id}" + + +def set_baby_server_status(server_id: str, status: dict[str, Any]) -> None: + """Persist the latest status payload for a server.""" + cache.set(_status_key(server_id), status) + + +def get_baby_server_status(server_id: str) -> dict[str, Any] | None: + """Fetch the cached status payload for a server, if present.""" + cached = cache.get(_status_key(server_id)) + return cached if isinstance(cached, dict) else None + + +def get_many_baby_server_statuses(server_ids: Iterable[str]) -> dict[str, dict[str, Any]]: + """Fetch cached statuses for a list of servers using a single multi-get call.""" + key_map = {_status_key(server_id): server_id for server_id in server_ids} + raw = cache.get_many(key_map.keys()) + return { + key_map[cache_key]: value for cache_key, value in raw.items() if isinstance(value, dict) and cache_key in key_map + } + + +def set_baby_server_heartbeat(server_id: str, timestamp: str) -> None: + """Persist the last-reported timestamp for a server.""" + cache.set(_heartbeat_key(server_id), timestamp) + + +def get_baby_server_heartbeat(server_id: str) -> str | None: + """Retrieve the cached heartbeat timestamp for a server.""" + cached = cache.get(_heartbeat_key(server_id)) + return cached if isinstance(cached, str) else None + + +def get_many_baby_server_heartbeats(server_ids: Iterable[str]) -> dict[str, str]: + """Fetch heartbeat timestamps for multiple servers in a single call.""" + key_map = {_heartbeat_key(server_id): server_id for server_id in server_ids} + raw = cache.get_many(key_map.keys()) + return { + key_map[cache_key]: value for cache_key, value in raw.items() if isinstance(value, str) and cache_key in key_map + } diff --git a/src/tests/accounts/endpoints/test_confirm_account.py b/src/tests/accounts/endpoints/test_confirm_account.py new file mode 100644 index 0000000..44e07f8 --- /dev/null +++ b/src/tests/accounts/endpoints/test_confirm_account.py @@ -0,0 +1,38 @@ +from django.core.cache import cache +from django.urls import reverse +from rest_framework import status +from rest_framework.test import APITestCase + +from accounts.models import Account, AccountConfirmation + + +class ConfirmAccountTest(APITestCase): + def setUp(self) -> None: + cache.clear() + self.account = Account.objects.create_user( + username="confirmUser", + email="confirm@example.com", + unique_identifier="confirmUser", + ) + self.account.set_password("aValidPss963") + self.account.is_confirmed = False + self.account.save() + + self.confirmation = AccountConfirmation.objects.create(account=self.account, token="confirm-token") # noqa: S106 - test-only credential + self.url = reverse("account:confirm") + + def test_confirm_account_with_valid_token(self) -> None: + response = self.client.post(self.url, {"token": self.confirmation.token}, format="json") + + self.assertEqual(response.status_code, status.HTTP_200_OK) + + self.account.refresh_from_db() + self.assertTrue(self.account.is_confirmed) + self.assertFalse(AccountConfirmation.objects.filter(pk=self.confirmation.pk).exists()) + + def test_confirm_account_with_invalid_token(self) -> None: + response = self.client.post(self.url, {"token": "invalid-token"}, format="json") + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.account.refresh_from_db() + self.assertFalse(self.account.is_confirmed) diff --git a/src/tests/accounts/endpoints/test_login_credentials.py b/src/tests/accounts/endpoints/test_login_credentials.py index e4cbe3b..a483dd9 100644 --- a/src/tests/accounts/endpoints/test_login_credentials.py +++ b/src/tests/accounts/endpoints/test_login_credentials.py @@ -1,3 +1,4 @@ +from django.core.cache import cache from django.urls import reverse from rest_framework import status from rest_framework.test import APITestCase @@ -7,6 +8,7 @@ class LoginCredentialsTest(APITestCase): def setUp(self): + cache.clear() self.url = reverse("account:login-credentials") self.valid_account = Account.objects.create_user( username="validUser", diff --git a/src/tests/accounts/endpoints/test_login_token.py b/src/tests/accounts/endpoints/test_login_token.py index fef0233..3482d1b 100644 --- a/src/tests/accounts/endpoints/test_login_token.py +++ b/src/tests/accounts/endpoints/test_login_token.py @@ -1,3 +1,4 @@ +from django.core.cache import cache from django.urls import reverse from rest_framework import status from rest_framework.test import APITestCase @@ -7,6 +8,7 @@ class LoginTokenTest(APITestCase): def setUp(self): + cache.clear() self.url = reverse("account:login-token") self.valid_account = Account.objects.create_user( username="validUser", diff --git a/src/tests/accounts/endpoints/test_register.py b/src/tests/accounts/endpoints/test_register.py index 9c712fe..796b2c3 100644 --- a/src/tests/accounts/endpoints/test_register.py +++ b/src/tests/accounts/endpoints/test_register.py @@ -1,3 +1,6 @@ +from unittest.mock import patch + +from django.core.cache import cache from django.urls import reverse from rest_framework import status from rest_framework.test import APITestCase @@ -7,6 +10,7 @@ class RegisterTest(APITestCase): def setUp(self): + cache.clear() self.url = reverse("account:register") self.valid_data = { "email": "validUser@valid.com", @@ -101,3 +105,15 @@ def test_confirmed_state_at_registering(self): self.assertEqual(response.status_code, status.HTTP_200_OK) created_account = Account.objects.get(email=self.valid_data["email"]) self.assertFalse(created_account.is_confirmed) + + def test_register_sends_confirmation_email(self): + data = self.valid_data.copy() + data["email"] = "newuser@example.com" + data["unique_identifier"] = "newuser" + data["username"] = "newuser" + + with patch("accounts.models.Account.send_confirmation_mail") as mock_send: + response = self.client.post(self.url, data, format="json") + + self.assertEqual(response.status_code, status.HTTP_200_OK) + mock_send.assert_called_once() diff --git a/src/tests/accounts/endpoints/test_reset_password.py b/src/tests/accounts/endpoints/test_reset_password.py index ffda7e4..870af95 100644 --- a/src/tests/accounts/endpoints/test_reset_password.py +++ b/src/tests/accounts/endpoints/test_reset_password.py @@ -1,3 +1,6 @@ +from unittest.mock import patch + +from django.core.cache import cache from django.urls import reverse from rest_framework import status from rest_framework.test import APITestCase @@ -11,6 +14,7 @@ def get_reset_url(token): class PasswordResetTest(APITestCase): def setUp(self): + cache.clear() self.valid_account = Account.objects.create_user( username="validUser", email="validUser@valid.com", @@ -37,6 +41,16 @@ def test_request_password_reset_with_valid_email(self): password_reset_request = PasswordResetRequestModel.objects.first() self.assertIsNotNone(password_reset_request) + def test_request_password_reset_sends_email(self): + data = {"email": "validUser@valid.com"} + + with patch("accounts.api.views.send_email_with_template") as mock_send: + response = self.client.post(self.url_request, data, format="json") + + self.assertEqual(response.status_code, status.HTTP_200_OK) + mock_send.assert_called_once() + self.assertEqual(mock_send.call_args.kwargs["recipient"], data["email"]) + def test_request_password_reset_with_invalid_email(self): data = {"email": "invalid@mail.com"} diff --git a/src/tests/accounts/endpoints/test_server_verification_token.py b/src/tests/accounts/endpoints/test_server_verification_token.py index 6e9037a..5ef528c 100644 --- a/src/tests/accounts/endpoints/test_server_verification_token.py +++ b/src/tests/accounts/endpoints/test_server_verification_token.py @@ -1,3 +1,4 @@ +from django.core.cache import cache from django.urls import reverse from rest_framework import status from rest_framework.test import APITestCase @@ -7,6 +8,7 @@ class ServerVerificationTokenTest(APITestCase): def setUp(self): + cache.clear() self.valid_account = Account.objects.create_user( username="validUser", email="validUser@valid.com", @@ -57,6 +59,8 @@ def test_verify_with_invalid_token(self): def test_verify_with_invalid_identifier(self): self.client.credentials(HTTP_AUTHORIZATION=f"Token {self.token}") response = self.client.get(self.url_request, format="json") + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIn("verification_token", response.data) data = {"verification_token": response.data["verification_token"], "unique_identifier": "invalidIdentifier"} response = self.client.post(self.url_verify, data, format="json") diff --git a/src/tests/baby_serverlist/__init__.py b/src/tests/baby_serverlist/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/tests/baby_serverlist/__init__.py @@ -0,0 +1 @@ + diff --git a/src/tests/baby_serverlist/test_api.py b/src/tests/baby_serverlist/test_api.py new file mode 100644 index 0000000..843fff4 --- /dev/null +++ b/src/tests/baby_serverlist/test_api.py @@ -0,0 +1,186 @@ +from datetime import UTC, datetime, timedelta +from uuid import uuid4 + +from django.core.cache import cache +from django.urls import reverse +from rest_framework import status +from rest_framework.test import APITestCase + +from accounts.models import Account +from baby_serverlist.models import BabyServer +from commons.cache import ( + get_baby_server_heartbeat, + get_baby_server_status, + set_baby_server_heartbeat, + set_baby_server_status, +) + + +def _sample_status_payload(server_token: str) -> dict[str, object]: + return { + "ServerToken": server_token, + "Passworded": False, + "ServerName": "Unitystation - Playtest Server", + "ForkName": "UnityStationDevelop", + "BuildVersion": 25103114, + "CurrentMap": "MainStations/SquareStation.json", + "GameMode": "Secret", + "IngameTime": "a lot", + "RoundTime": "0", + "PlayerCount": 0, + "PlayerCountMax": 45, + "ServerIP": "127.0.0.1", + "ServerPort": 7777, + "WinDownload": "https://example.com/win.zip", + "OSXDownload": "https://example.com/osx.zip", + "LinuxDownload": "https://example.com/linux.zip", + "fps": 98, + "GoodFileVersion": "0.31.0", + } + + +class BabyServerAPITests(APITestCase): + def setUp(self) -> None: + self.user = Account.objects.create_user( + email="owner@example.com", + password="password123", # noqa: S106 - test-only credential + unique_identifier="owner", + username="Owner", + ) + self.other_user = Account.objects.create_user( + email="other@example.com", + password="password123", # noqa: S106 - test-only credential + unique_identifier="otheruser", + username="Other", + ) + + def tearDown(self) -> None: + cache.clear() + + def test_create_baby_server_returns_token(self) -> None: + self.client.force_authenticate(self.user) + + response = self.client.post(reverse("baby_serverlist:create")) + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + payload = response.json() + self.assertIn("serverlist_token", payload) + self.assertTrue(BabyServer.objects.filter(id=payload["id"]).exists()) + + def test_regenerate_token_changes_token_for_owner(self) -> None: + self.client.force_authenticate(self.user) + baby_server = BabyServer.objects.create(owner=self.user) + original_token = baby_server.serverlist_token + + response = self.client.post( + reverse("baby_serverlist:regenerate-token"), + {"server_id": str(baby_server.id)}, + format="json", + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + baby_server.refresh_from_db() + self.assertNotEqual(original_token, baby_server.serverlist_token) + + def test_regenerate_token_rejects_non_owner(self) -> None: + baby_server = BabyServer.objects.create(owner=self.user) + self.client.force_authenticate(self.other_user) + + response = self.client.post( + reverse("baby_serverlist:regenerate-token"), + {"server_id": str(baby_server.id)}, + format="json", + ) + + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_regenerate_token_not_found_returns_404(self) -> None: + self.client.force_authenticate(self.user) + + response = self.client.post( + reverse("baby_serverlist:regenerate-token"), + {"server_id": str(uuid4())}, + format="json", + ) + + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + def test_post_server_status_stores_payload_in_cache(self) -> None: + baby_server = BabyServer.objects.create(owner=self.user, whitelisted=True) + payload = _sample_status_payload(baby_server.serverlist_token) + + response = self.client.post( + reverse("baby_serverlist:report-status"), + payload, + format="json", + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + cached_status = get_baby_server_status(str(baby_server.id)) + self.assertIsNotNone(cached_status) + cached_status = cached_status or {} + self.assertNotIn("ServerToken", cached_status) + self.assertEqual(cached_status["ServerName"], payload["ServerName"]) + + cached_heartbeat = get_baby_server_heartbeat(str(baby_server.id)) + self.assertIsNotNone(cached_heartbeat) + + def test_post_server_status_rejects_invalid_token(self) -> None: + baby_server = BabyServer.objects.create(owner=self.user) + payload = _sample_status_payload("invalid-token") + + response = self.client.post( + reverse("baby_serverlist:report-status"), + payload, + format="json", + ) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + cached_status = get_baby_server_status(str(baby_server.id)) + self.assertIsNone(cached_status) + + def test_list_owned_baby_servers_live_flag(self) -> None: + self.client.force_authenticate(self.user) + baby_server = BabyServer.objects.create(owner=self.user) + + response = self.client.get(reverse("baby_serverlist:list-owned")) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertFalse(response.json()[0]["live"]) + + set_baby_server_heartbeat(str(baby_server.id), datetime.now(tz=UTC).isoformat()) + + response = self.client.get(reverse("baby_serverlist:list-owned")) + self.assertTrue(response.json()[0]["live"]) + + stale_time = datetime.now(tz=UTC) - timedelta(seconds=13) + set_baby_server_heartbeat(str(baby_server.id), stale_time.isoformat()) + + response = self.client.get(reverse("baby_serverlist:list-owned")) + self.assertFalse(response.json()[0]["live"]) + + def test_list_baby_servers_returns_whitelisted_status(self) -> None: + baby_server = BabyServer.objects.create(owner=self.user, whitelisted=True) + status_data = _sample_status_payload(baby_server.serverlist_token) + status_without_token = status_data.copy() + status_without_token.pop("ServerToken") + + set_baby_server_status(str(baby_server.id), status_without_token) + + response = self.client.get(reverse("baby_serverlist:list")) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + payload = response.json() + self.assertIn("servers", payload) + self.assertEqual(len(payload["servers"]), 1) + self.assertEqual(payload["servers"][0]["ServerName"], status_without_token["ServerName"]) + + def test_list_baby_servers_ignores_non_whitelisted(self) -> None: + non_whitelisted = BabyServer.objects.create(owner=self.user, whitelisted=False) + set_baby_server_status(str(non_whitelisted.id), {"ServerName": "Hidden"}) + + response = self.client.get(reverse("baby_serverlist:list")) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.json(), {"servers": []}) diff --git a/uv.lock b/uv.lock index 9fdcec6..b35a0d0 100644 --- a/uv.lock +++ b/uv.lock @@ -49,6 +49,7 @@ dependencies = [ { name = "drf-spectacular" }, { name = "gunicorn" }, { name = "psycopg2-binary" }, + { name = "pymemcache" }, { name = "python-dotenv" }, { name = "whitenoise" }, ] @@ -71,6 +72,7 @@ requires-dist = [ { name = "drf-spectacular", specifier = "~=0.27.1" }, { name = "gunicorn", specifier = "~=20.1.0" }, { name = "psycopg2-binary", specifier = "~=2.9.9" }, + { name = "pymemcache", specifier = ">=4.0,<5.0" }, { name = "python-dotenv", specifier = "~=0.19.2" }, { name = "whitenoise", specifier = "~=6.6.0" }, ] @@ -479,6 +481,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/13/a3/a812df4e2dd5696d1f351d58b8fe16a405b234ad2886a0dab9183fb78109/pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc", size = 117552 }, ] +[[package]] +name = "pymemcache" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d9/b6/4541b664aeaad025dfb8e851dcddf8e25ab22607e674dd2b562ea3e3586f/pymemcache-4.0.0.tar.gz", hash = "sha256:27bf9bd1bbc1e20f83633208620d56de50f14185055e49504f4f5e94e94aff94", size = 70176 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/ba/2f7b22d8135b51c4fefb041461f8431e1908778e6539ff5af6eeaaee367a/pymemcache-4.0.0-py2.py3-none-any.whl", hash = "sha256:f507bc20e0dc8d562f8df9d872107a278df049fa496805c1431b926f3ddd0eab", size = 60772 }, +] + [[package]] name = "python-dotenv" version = "0.19.2" From b0820ad8831d993901114ac55b66754bf798ce69 Mon Sep 17 00:00:00 2001 From: Gilles <43683714+corp-0@users.noreply.github.com> Date: Sat, 1 Nov 2025 09:17:20 -0300 Subject: [PATCH 07/13] feat: improved functionality of admin view for BabyServer (#103) (cherry picked from commit 367671922deb9a77ed554bf93b5b28945ca25661) --- src/baby_serverlist/admin.py | 18 +++++++++++++++++- .../babyserver/change_form.html | 17 +++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) create mode 100644 src/templates/admin/baby_serverlist/babyserver/change_form.html diff --git a/src/baby_serverlist/admin.py b/src/baby_serverlist/admin.py index fa52808..b434279 100644 --- a/src/baby_serverlist/admin.py +++ b/src/baby_serverlist/admin.py @@ -1,10 +1,26 @@ from django.contrib import admin +from django.http import HttpRequest, HttpResponse, HttpResponseRedirect from .models import BabyServer @admin.register(BabyServer) class BabyServerAdmin(admin.ModelAdmin): - list_display = ("id", "owner", "whitelisted", "serverlist_token") + list_display = ("id", "owner", "whitelisted", "serverlist_token_display") search_fields = ("id", "owner__email", "owner__unique_identifier") list_filter = ("whitelisted",) + readonly_fields = ("serverlist_token",) + change_form_template = "admin/baby_serverlist/babyserver/change_form.html" + + @admin.display(description="Server token", ordering="serverlist_token") + def serverlist_token_display(self, obj: BabyServer) -> str: + # Return the full token so it can be copied without Django's default truncation. + return obj.serverlist_token + + def response_change(self, request: HttpRequest, obj: BabyServer) -> HttpResponse: + if "_regenerate_token" in request.POST: + obj.serverlist_token = obj.generate_serverlist_token() + obj.save(update_fields=["serverlist_token"]) + self.message_user(request, "Server token regenerated.") + return HttpResponseRedirect(request.path) + return super().response_change(request, obj) diff --git a/src/templates/admin/baby_serverlist/babyserver/change_form.html b/src/templates/admin/baby_serverlist/babyserver/change_form.html new file mode 100644 index 0000000..4b4da2f --- /dev/null +++ b/src/templates/admin/baby_serverlist/babyserver/change_form.html @@ -0,0 +1,17 @@ +{% extends "admin/change_form.html" %} +{% load i18n %} + +{% block object-tools-items %} + {{ block.super }} + {% if original %} +
  • + +
  • + {% endif %} +{% endblock %} From 369d27823610345545c70841c5f212f9ee20ecf5 Mon Sep 17 00:00:00 2001 From: Gilles <43683714+corp-0@users.noreply.github.com> Date: Sun, 2 Nov 2025 20:25:05 -0300 Subject: [PATCH 08/13] feat: annotate endpoint responses for auto documentation (#104) --- src/baby_serverlist/api/serializers.py | 38 +++++++++++++-- src/baby_serverlist/api/views.py | 66 +++++++++++++++++++++++--- 2 files changed, 94 insertions(+), 10 deletions(-) diff --git a/src/baby_serverlist/api/serializers.py b/src/baby_serverlist/api/serializers.py index d381fa3..945b57c 100644 --- a/src/baby_serverlist/api/serializers.py +++ b/src/baby_serverlist/api/serializers.py @@ -22,9 +22,41 @@ class ServerStatusSerializer(serializers.Serializer): GoodFileVersion = serializers.CharField() -class ActiveServersSerializer(serializers.Serializer): - CashDateTime = serializers.DateTimeField() - servers = ServerStatusSerializer(many=True) +class CachedServerStatusSerializer(serializers.Serializer): + Passworded = serializers.BooleanField() + ServerName = serializers.CharField() + ForkName = serializers.CharField() + BuildVersion = serializers.IntegerField() + CurrentMap = serializers.CharField() + GameMode = serializers.CharField() + IngameTime = serializers.CharField() + RoundTime = serializers.CharField() + PlayerCount = serializers.IntegerField() + PlayerCountMax = serializers.IntegerField() + ServerIP = serializers.CharField() + ServerPort = serializers.IntegerField() + WinDownload = serializers.CharField() + OSXDownload = serializers.CharField() + LinuxDownload = serializers.CharField() + fps = serializers.IntegerField() + GoodFileVersion = serializers.CharField() + + +class OwnedBabyServerSerializer(serializers.Serializer): + id = serializers.UUIDField() + whitelisted = serializers.BooleanField() + live = serializers.BooleanField() + status = CachedServerStatusSerializer(allow_null=True) + + +class BabyServerTokenSerializer(serializers.Serializer): + id = serializers.UUIDField() + serverlist_token = serializers.CharField() + whitelisted = serializers.BooleanField() + + +class BabyServerStatusListSerializer(serializers.Serializer): + servers = CachedServerStatusSerializer(many=True) class RegenerateServerlistTokenSerializer(serializers.Serializer): diff --git a/src/baby_serverlist/api/views.py b/src/baby_serverlist/api/views.py index 4b054a6..411f3a9 100644 --- a/src/baby_serverlist/api/views.py +++ b/src/baby_serverlist/api/views.py @@ -4,6 +4,7 @@ from typing import cast from django.core import signing +from drf_spectacular.utils import OpenApiResponse, extend_schema, extend_schema_view from rest_framework import status from rest_framework.generics import GenericAPIView, ListAPIView from rest_framework.permissions import AllowAny @@ -12,19 +13,38 @@ from accounts.models import Account from baby_serverlist.models import SERVERLIST_TOKEN_SALT, BabyServer from commons.cache import ( + get_baby_server_status, get_many_baby_server_statuses, set_baby_server_heartbeat, set_baby_server_status, ) from commons.error_response import ErrorResponse -from .serializers import RegenerateServerlistTokenSerializer, ServerStatusSerializer +from .serializers import ( + BabyServerStatusListSerializer, + BabyServerTokenSerializer, + OwnedBabyServerSerializer, + RegenerateServerlistTokenSerializer, + ServerStatusSerializer, +) logger = logging.getLogger(__name__) +@extend_schema_view( + post=extend_schema( + request=ServerStatusSerializer, + responses={ + 200: OpenApiResponse(description="Status accepted and cached"), + 400: OpenApiResponse(description="Invalid payload or signature"), + }, + ) +) class PostServerStatusView(GenericAPIView): - """Accepts signed status payloads from baby servers and stores the latest state in cache.""" + """Accepts signed status payloads from baby servers and stores the latest state in cache. + + *** Public Endpoint *** + """ serializer_class = ServerStatusSerializer permission_classes = (AllowAny,) @@ -59,14 +79,21 @@ def post(self, request, *args, **kwargs): return Response(status=status.HTTP_200_OK) +@extend_schema_view( + post=extend_schema( + responses={201: BabyServerTokenSerializer}, + ) +) class CreateBabyServerView(GenericAPIView): - """Creates a new baby server for the authenticated user and returns the freshly minted token.""" + """Creates a new baby server for the authenticated user and returns the freshly minted token. + + *** Requires Token Authentication. *** + """ queryset = BabyServer.objects.all() def post(self, request, *args, **kwargs): user = cast(Account, request.user) - baby_server = BabyServer.objects.create(owner=user) return Response( @@ -79,8 +106,16 @@ def post(self, request, *args, **kwargs): ) +@extend_schema_view( + list=extend_schema( + responses={200: OwnedBabyServerSerializer(many=True)}, + ) +) class ListOwnedBabyServersView(ListAPIView): - """Lists the caller's baby servers with a derived `live` flag based on recent heartbeats.""" + """Lists the caller's baby servers with a derived `live` flag based on recent heartbeats. + + *** Requires Token Authentication. *** + """ def get_queryset(self): user = cast(Account, self.request.user) @@ -94,14 +129,23 @@ def list(self, request, *args, **kwargs): "id": str(server.id), "whitelisted": server.whitelisted, "live": server.is_live(), + "status": get_baby_server_status(server.id), } for server in queryset ] return Response(data, status=status.HTTP_200_OK) +@extend_schema_view( + list=extend_schema( + responses={200: BabyServerStatusListSerializer}, + ) +) class ListBabyServersView(ListAPIView): - """Return cached status payloads for all baby servers that have reported recently.""" + """Return cached status payloads for all baby servers that have reported recently. + + *** Public Endpoint *** + """ permission_classes = (AllowAny,) @@ -117,8 +161,16 @@ def list(self, request, *args, **kwargs): return Response({"servers": data}, status=status.HTTP_200_OK) +@extend_schema_view( + post=extend_schema( + responses={200: BabyServerTokenSerializer}, + ) +) class RegenerateServerlistTokenView(GenericAPIView): - """Regenerates a server's signed token after validating ownership.""" + """Regenerates a server's signed token after validating ownership. + + *** Requires Token Authentication. *** + """ serializer_class = RegenerateServerlistTokenSerializer queryset = BabyServer.objects.all() From c1272badac296ce6719f4d581181ee8e6bf7cafc Mon Sep 17 00:00:00 2001 From: Gilles <43683714+corp-0@users.noreply.github.com> Date: Fri, 7 Nov 2025 20:10:30 -0300 Subject: [PATCH 09/13] feat: status on cache are now actually ephemeral (#105) --- src/baby_serverlist/models.py | 10 +++++--- src/commons/cache.py | 7 ++++-- src/tests/baby_serverlist/test_api.py | 24 +++++++++++++++++-- src/tests/commons/__init__.py | 1 + src/tests/commons/test_cache.py | 33 +++++++++++++++++++++++++++ 5 files changed, 68 insertions(+), 7 deletions(-) create mode 100644 src/tests/commons/__init__.py create mode 100644 src/tests/commons/test_cache.py diff --git a/src/baby_serverlist/models.py b/src/baby_serverlist/models.py index 1e893a9..7725e00 100644 --- a/src/baby_serverlist/models.py +++ b/src/baby_serverlist/models.py @@ -6,9 +6,10 @@ from django.db import models from accounts.models import Account -from commons.cache import get_baby_server_heartbeat +from commons.cache import BABY_SERVER_HEARTBEAT_TTL_SECONDS, get_baby_server_heartbeat SERVERLIST_TOKEN_SALT = "baby_serverlist.serverlist_token" +LIVE_HEARTBEAT_GRACE_SECONDS = 2 class BabyServer(models.Model): @@ -40,7 +41,7 @@ def generate_serverlist_token(self) -> str: return signing.dumps(payload, salt=SERVERLIST_TOKEN_SALT) def is_live(self) -> bool: - """Return True when the server has reported within the last 12 seconds.""" + """Return True when the server has reported within the heartbeat TTL window.""" heartbeat_iso = get_baby_server_heartbeat(str(self.id)) if not heartbeat_iso: return False @@ -50,4 +51,7 @@ def is_live(self) -> bool: return False if heartbeat_time.tzinfo is None: heartbeat_time = heartbeat_time.replace(tzinfo=UTC) - return datetime.now(tz=UTC) - heartbeat_time <= timedelta(seconds=12) + + # live if last heartbeat within the cache TTL plus a small grace buffer + ttl_with_grace = BABY_SERVER_HEARTBEAT_TTL_SECONDS + LIVE_HEARTBEAT_GRACE_SECONDS + return datetime.now(tz=UTC) - heartbeat_time <= timedelta(seconds=ttl_with_grace) diff --git a/src/commons/cache.py b/src/commons/cache.py index 31e2075..94c380e 100644 --- a/src/commons/cache.py +++ b/src/commons/cache.py @@ -3,6 +3,9 @@ from django.core.cache import cache +BABY_SERVER_STATUS_TTL_SECONDS = 10 +BABY_SERVER_HEARTBEAT_TTL_SECONDS = 10 + SERVER_STATUS_KEY_PREFIX = "baby_server_status:" SERVER_HEARTBEAT_KEY_PREFIX = "baby_server_heartbeat:" @@ -19,7 +22,7 @@ def _heartbeat_key(server_id: str) -> str: def set_baby_server_status(server_id: str, status: dict[str, Any]) -> None: """Persist the latest status payload for a server.""" - cache.set(_status_key(server_id), status) + cache.set(_status_key(server_id), status, timeout=BABY_SERVER_STATUS_TTL_SECONDS) def get_baby_server_status(server_id: str) -> dict[str, Any] | None: @@ -39,7 +42,7 @@ def get_many_baby_server_statuses(server_ids: Iterable[str]) -> dict[str, dict[s def set_baby_server_heartbeat(server_id: str, timestamp: str) -> None: """Persist the last-reported timestamp for a server.""" - cache.set(_heartbeat_key(server_id), timestamp) + cache.set(_heartbeat_key(server_id), timestamp, timeout=BABY_SERVER_HEARTBEAT_TTL_SECONDS) def get_baby_server_heartbeat(server_id: str) -> str | None: diff --git a/src/tests/baby_serverlist/test_api.py b/src/tests/baby_serverlist/test_api.py index 843fff4..d5d01e2 100644 --- a/src/tests/baby_serverlist/test_api.py +++ b/src/tests/baby_serverlist/test_api.py @@ -7,8 +7,9 @@ from rest_framework.test import APITestCase from accounts.models import Account -from baby_serverlist.models import BabyServer +from baby_serverlist.models import LIVE_HEARTBEAT_GRACE_SECONDS, BabyServer from commons.cache import ( + BABY_SERVER_HEARTBEAT_TTL_SECONDS, get_baby_server_heartbeat, get_baby_server_status, set_baby_server_heartbeat, @@ -154,7 +155,9 @@ def test_list_owned_baby_servers_live_flag(self) -> None: response = self.client.get(reverse("baby_serverlist:list-owned")) self.assertTrue(response.json()[0]["live"]) - stale_time = datetime.now(tz=UTC) - timedelta(seconds=13) + stale_time = datetime.now(tz=UTC) - timedelta( + seconds=BABY_SERVER_HEARTBEAT_TTL_SECONDS + LIVE_HEARTBEAT_GRACE_SECONDS + 1 + ) set_baby_server_heartbeat(str(baby_server.id), stale_time.isoformat()) response = self.client.get(reverse("baby_serverlist:list-owned")) @@ -184,3 +187,20 @@ def test_list_baby_servers_ignores_non_whitelisted(self) -> None: self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.json(), {"servers": []}) + + def test_baby_server_is_live_respects_heartbeat_ttl(self) -> None: + baby_server = BabyServer.objects.create(owner=self.user) + + fresh_time = datetime.now(tz=UTC) - timedelta( + seconds=BABY_SERVER_HEARTBEAT_TTL_SECONDS + LIVE_HEARTBEAT_GRACE_SECONDS - 1 + ) + set_baby_server_heartbeat(str(baby_server.id), fresh_time.isoformat()) + self.assertTrue(baby_server.is_live()) + + stale_time = datetime.now(tz=UTC) - timedelta( + seconds=BABY_SERVER_HEARTBEAT_TTL_SECONDS + LIVE_HEARTBEAT_GRACE_SECONDS + 1 + ) + set_baby_server_heartbeat(str(baby_server.id), stale_time.isoformat()) + stored = get_baby_server_heartbeat(str(baby_server.id)) + self.assertEqual(stored, stale_time.isoformat()) + self.assertFalse(baby_server.is_live()) diff --git a/src/tests/commons/__init__.py b/src/tests/commons/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/tests/commons/__init__.py @@ -0,0 +1 @@ + diff --git a/src/tests/commons/test_cache.py b/src/tests/commons/test_cache.py new file mode 100644 index 0000000..0a8984d --- /dev/null +++ b/src/tests/commons/test_cache.py @@ -0,0 +1,33 @@ +from unittest.mock import patch + +from django.test import SimpleTestCase + +from commons import cache as cache_module + + +class CommonsCacheTests(SimpleTestCase): + def test_set_baby_server_status_uses_ephemeral_timeout(self) -> None: + payload = {"ServerName": "test"} + server_id = "server-123" + + with patch.object(cache_module, "cache") as fake_cache: + cache_module.set_baby_server_status(server_id, payload) + + fake_cache.set.assert_called_once_with( + f"{cache_module.SERVER_STATUS_KEY_PREFIX}{server_id}", + payload, + timeout=cache_module.BABY_SERVER_STATUS_TTL_SECONDS, + ) + + def test_set_baby_server_heartbeat_uses_ephemeral_timeout(self) -> None: + timestamp = "2024-01-01T00:00:00+00:00" + server_id = "server-456" + + with patch.object(cache_module, "cache") as fake_cache: + cache_module.set_baby_server_heartbeat(server_id, timestamp) + + fake_cache.set.assert_called_once_with( + f"{cache_module.SERVER_HEARTBEAT_KEY_PREFIX}{server_id}", + timestamp, + timeout=cache_module.BABY_SERVER_HEARTBEAT_TTL_SECONDS, + ) From 9d94c7bec90e34d9a3b9db1c8cc760a075afa55c Mon Sep 17 00:00:00 2001 From: Gilles <43683714+corp-0@users.noreply.github.com> Date: Thu, 13 Nov 2025 19:39:03 -0300 Subject: [PATCH 10/13] fix: upgrade django version and all deps (#106) * feat: update django and all deps * feat: add unit test for mail sending feature --- pyproject.toml | 31 ++- src/accounts/api/views.py | 2 +- src/tests/test_post_office_smtp.py | 71 +++++++ uv.lock | 307 +++++++++++++---------------- 4 files changed, 222 insertions(+), 189 deletions(-) create mode 100644 src/tests/test_post_office_smtp.py diff --git a/pyproject.toml b/pyproject.toml index a5a46d0..270087f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,15 +4,15 @@ version = "0.1.0" requires-python = "==3.12.*" dependencies = [ - "Django~=3.2.24", - "django-post-office~=3.8.0", - "djangorestframework~=3.14.0", - "django-rest-knox~=4.2.0", - "drf-spectacular~=0.27.1", - "gunicorn~=20.1.0", - "psycopg2-binary~=2.9.9", - "python-dotenv~=0.19.2", - "whitenoise~=6.6.0", + "Django~=5.2.0", + "django-post-office~=3.10.0", + "djangorestframework~=3.16.0", + "django-rest-knox~=5.0.0", + "drf-spectacular~=0.29.0", + "gunicorn~=23.0.0", + "psycopg2-binary~=2.9.10", + "python-dotenv~=1.0", + "whitenoise~=6.11.0", "pymemcache>=4.0,<5.0", ] @@ -25,13 +25,12 @@ packages = ["src/central_command"] [tool.uv] dev-dependencies = [ - "pre-commit~=4.0", - "ruff~=0.7", - "mypy>=1.7.1", - # TODO: bump django and stubs. these stubs seem to be incompatible with django 3.2 - # https://github.com/typeddjango/django-stubs/tree/master?tab=readme-ov-file#version-compatibility - "django-stubs[compatible-mypy]==4.2.7", - "djangorestframework-stubs[compatible-mypy]==3.14.5", + "aiosmtpd~=1.4.5", + "pre-commit~=4.4", + "ruff~=0.14.0", + "mypy>=1.18.0", + "django-stubs[compatible-mypy]==5.2.7", + "djangorestframework-stubs[compatible-mypy]==3.16.5", ] [tool.ruff] diff --git a/src/accounts/api/views.py b/src/accounts/api/views.py index 9a0cfd0..e6ba4c3 100644 --- a/src/accounts/api/views.py +++ b/src/accounts/api/views.py @@ -91,7 +91,7 @@ def post(self, request): email = serializer.validated_data["email"] password = serializer.validated_data["password"] - account: Account | None = authenticate(email=email, password=password) # type: ignore[assignment] + account: Account | None = authenticate(email=email, password=password) if account is None: return ErrorResponse( diff --git a/src/tests/test_post_office_smtp.py b/src/tests/test_post_office_smtp.py new file mode 100644 index 0000000..c79e347 --- /dev/null +++ b/src/tests/test_post_office_smtp.py @@ -0,0 +1,71 @@ +import os +import socket +import tempfile + +from pathlib import Path + +from aiosmtpd.controller import Controller +from django.core.management import call_command +from django.test import TestCase, override_settings +from post_office import mail +from post_office.models import STATUS, Email + + +class _InMemorySMTPHandler: + def __init__(self): + self.envelopes = [] + + async def handle_DATA(self, server, session, envelope): # noqa: N802 the name is required by aiosmtpd + self.envelopes.append(envelope) + return "250 OK" + + +def _allocate_port(): + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.bind(("127.0.0.1", 0)) + try: + return sock.getsockname()[1] + finally: + sock.close() + + +class PostOfficeSMTPIntegrationTest(TestCase): + def setUp(self): + self.smtp_handler = _InMemorySMTPHandler() + self.smtp_port = _allocate_port() + self.smtp_controller = Controller(self.smtp_handler, hostname="127.0.0.1", port=self.smtp_port) + self.smtp_controller.start() + self.addCleanup(self.smtp_controller.stop) + + def test_queued_email_is_delivered_via_local_debug_server(self): + recipients = ["recipient@example.com"] + sender = "sender@example.com" + subject = "Queue smoke test" + body = "Hello from the queue" + + with override_settings( + EMAIL_BACKEND="post_office.EmailBackend", + EMAIL_HOST="127.0.0.1", + EMAIL_PORT=self.smtp_port, + EMAIL_USE_TLS=False, + EMAIL_HOST_USER="", + EMAIL_HOST_PASSWORD="", + ): + mail.send(recipients=recipients, sender=sender, subject=subject, message=body) + + self.assertEqual(Email.objects.filter(status=STATUS.queued).count(), 1) + + fd, lockfile_path = tempfile.mkstemp() + os.close(fd) + try: + call_command("send_queued_mail", processes=1, lockfile=lockfile_path, verbosity=0) + finally: + Path(lockfile_path).unlink() + + sent_emails = Email.objects.filter(status=STATUS.sent) + self.assertEqual(sent_emails.count(), 1) + self.assertEqual(len(self.smtp_handler.envelopes), 1) + envelope = self.smtp_handler.envelopes[0] + self.assertEqual(envelope.mail_from, sender) + self.assertEqual(envelope.rcpt_tos, recipients) + self.assertIn(subject, envelope.content.decode()) diff --git a/uv.lock b/uv.lock index b35a0d0..317cd9f 100644 --- a/uv.lock +++ b/uv.lock @@ -1,6 +1,19 @@ version = 1 requires-python = "==3.12.*" +[[package]] +name = "aiosmtpd" +version = "1.4.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "atpublic" }, + { name = "attrs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c4/ca/b2b7cc880403ef24be77383edaadfcf0098f5d7b9ddbf3e2c17ef0a6af0d/aiosmtpd-1.4.6.tar.gz", hash = "sha256:5a811826e1a5a06c25ebc3e6c4a704613eb9a1bcf6b78428fbe865f4f6c9a4b8", size = 152775 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/39/d401756df60a8344848477d54fdf4ce0f50531f6149f3b8eaae9c06ae3dc/aiosmtpd-1.4.6-py3-none-any.whl", hash = "sha256:72c99179ba5aa9ae0abbda6994668239b64a5ce054471955fe75f581d2592475", size = 154263 }, +] + [[package]] name = "asgiref" version = "3.8.1" @@ -10,6 +23,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/39/e3/893e8757be2612e6c266d9bb58ad2e3651524b5b40cf56761e985a28b13e/asgiref-3.8.1-py3-none-any.whl", hash = "sha256:3e1e3ecc849832fe52ccf2cb6686b7a55f82bb1d6aee72a58826471390335e47", size = 23828 }, ] +[[package]] +name = "atpublic" +version = "6.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/78/a7c9b6d6581353204a7a099567783dd3352405b1662988892b9e67039c6c/atpublic-6.0.2.tar.gz", hash = "sha256:f90dcd17627ac21d5ce69e070d6ab89fb21736eb3277e8b693cc8484e1c7088c", size = 17708 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/72/da/8916af0a074d24354d685fe4178a52d3fafd07b62e6f81124fdeac15594d/atpublic-6.0.2-py3-none-any.whl", hash = "sha256:156cfd3854e580ebfa596094a018fe15e4f3fa5bade74b39c3dabb54f12d6565", size = 6423 }, +] + [[package]] name = "attrs" version = "24.2.0" @@ -56,6 +78,7 @@ dependencies = [ [package.dev-dependencies] dev = [ + { name = "aiosmtpd" }, { name = "django-stubs", extra = ["compatible-mypy"] }, { name = "djangorestframework-stubs", extra = ["compatible-mypy"] }, { name = "mypy" }, @@ -65,25 +88,26 @@ dev = [ [package.metadata] requires-dist = [ - { name = "django", specifier = "~=3.2.24" }, - { name = "django-post-office", specifier = "~=3.8.0" }, - { name = "django-rest-knox", specifier = "~=4.2.0" }, - { name = "djangorestframework", specifier = "~=3.14.0" }, - { name = "drf-spectacular", specifier = "~=0.27.1" }, - { name = "gunicorn", specifier = "~=20.1.0" }, - { name = "psycopg2-binary", specifier = "~=2.9.9" }, + { name = "django", specifier = "~=5.2.0" }, + { name = "django-post-office", specifier = "~=3.10.0" }, + { name = "django-rest-knox", specifier = "~=5.0.0" }, + { name = "djangorestframework", specifier = "~=3.16.0" }, + { name = "drf-spectacular", specifier = "~=0.29.0" }, + { name = "gunicorn", specifier = "~=23.0.0" }, + { name = "psycopg2-binary", specifier = "~=2.9.10" }, { name = "pymemcache", specifier = ">=4.0,<5.0" }, - { name = "python-dotenv", specifier = "~=0.19.2" }, - { name = "whitenoise", specifier = "~=6.6.0" }, + { name = "python-dotenv", specifier = "~=1.0" }, + { name = "whitenoise", specifier = "~=6.11.0" }, ] [package.metadata.requires-dev] dev = [ - { name = "django-stubs", extras = ["compatible-mypy"], specifier = "==4.2.7" }, - { name = "djangorestframework-stubs", extras = ["compatible-mypy"], specifier = "==3.14.5" }, - { name = "mypy", specifier = ">=1.7.1" }, - { name = "pre-commit", specifier = "~=4.0" }, - { name = "ruff", specifier = "~=0.7" }, + { name = "aiosmtpd", specifier = "~=1.4.5" }, + { name = "django-stubs", extras = ["compatible-mypy"], specifier = "==5.2.7" }, + { name = "djangorestframework-stubs", extras = ["compatible-mypy"], specifier = "==3.16.5" }, + { name = "mypy", specifier = ">=1.18.0" }, + { name = "pre-commit", specifier = "~=4.4" }, + { name = "ruff", specifier = "~=0.14.0" }, ] [[package]] @@ -95,28 +119,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/12/90/3c9ff0512038035f59d279fddeb79f5f1eccd8859f06d6163c58798b9487/certifi-2024.8.30-py3-none-any.whl", hash = "sha256:922820b53db7a7257ffbda3f597266d435245903d80737e34f8a45ff3e3230d8", size = 167321 }, ] -[[package]] -name = "cffi" -version = "1.17.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pycparser" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/fc/97/c783634659c2920c3fc70419e3af40972dbaf758daa229a7d6ea6135c90d/cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824", size = 516621 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5a/84/e94227139ee5fb4d600a7a4927f322e1d4aea6fdc50bd3fca8493caba23f/cffi-1.17.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4", size = 183178 }, - { url = "https://files.pythonhosted.org/packages/da/ee/fb72c2b48656111c4ef27f0f91da355e130a923473bf5ee75c5643d00cca/cffi-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c", size = 178840 }, - { url = "https://files.pythonhosted.org/packages/cc/b6/db007700f67d151abadf508cbfd6a1884f57eab90b1bb985c4c8c02b0f28/cffi-1.17.1-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36", size = 454803 }, - { url = "https://files.pythonhosted.org/packages/1a/df/f8d151540d8c200eb1c6fba8cd0dfd40904f1b0682ea705c36e6c2e97ab3/cffi-1.17.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5", size = 478850 }, - { url = "https://files.pythonhosted.org/packages/28/c0/b31116332a547fd2677ae5b78a2ef662dfc8023d67f41b2a83f7c2aa78b1/cffi-1.17.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff", size = 485729 }, - { url = "https://files.pythonhosted.org/packages/91/2b/9a1ddfa5c7f13cab007a2c9cc295b70fbbda7cb10a286aa6810338e60ea1/cffi-1.17.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99", size = 471256 }, - { url = "https://files.pythonhosted.org/packages/b2/d5/da47df7004cb17e4955df6a43d14b3b4ae77737dff8bf7f8f333196717bf/cffi-1.17.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93", size = 479424 }, - { url = "https://files.pythonhosted.org/packages/0b/ac/2a28bcf513e93a219c8a4e8e125534f4f6db03e3179ba1c45e949b76212c/cffi-1.17.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3", size = 484568 }, - { url = "https://files.pythonhosted.org/packages/d4/38/ca8a4f639065f14ae0f1d9751e70447a261f1a30fa7547a828ae08142465/cffi-1.17.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8", size = 488736 }, - { url = "https://files.pythonhosted.org/packages/86/c5/28b2d6f799ec0bdecf44dced2ec5ed43e0eb63097b0f58c293583b406582/cffi-1.17.1-cp312-cp312-win32.whl", hash = "sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65", size = 172448 }, - { url = "https://files.pythonhosted.org/packages/50/b9/db34c4755a7bd1cb2d1603ac3863f22bcecbd1ba29e5ee841a4bc510b294/cffi-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903", size = 181976 }, -] - [[package]] name = "cfgv" version = "3.4.0" @@ -150,35 +152,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/bf/9b/08c0432272d77b04803958a4598a51e2a4b51c06640af8b8f0f908c18bf2/charset_normalizer-3.4.0-py3-none-any.whl", hash = "sha256:fe9f97feb71aa9896b81973a7bbada8c49501dc73e58a10fcef6663af95e5079", size = 49446 }, ] -[[package]] -name = "cryptography" -version = "43.0.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/de/ba/0664727028b37e249e73879348cc46d45c5c1a2a2e81e8166462953c5755/cryptography-43.0.1.tar.gz", hash = "sha256:203e92a75716d8cfb491dc47c79e17d0d9207ccffcbcb35f598fbe463ae3444d", size = 686927 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/58/28/b92c98a04ba762f8cdeb54eba5c4c84e63cac037a7c5e70117d337b15ad6/cryptography-43.0.1-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:8385d98f6a3bf8bb2d65a73e17ed87a3ba84f6991c155691c51112075f9ffc5d", size = 6223222 }, - { url = "https://files.pythonhosted.org/packages/33/13/1193774705783ba364121aa2a60132fa31a668b8ababd5edfa1662354ccd/cryptography-43.0.1-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:27e613d7077ac613e399270253259d9d53872aaf657471473ebfc9a52935c062", size = 3794751 }, - { url = "https://files.pythonhosted.org/packages/5e/4b/39bb3c4c8cfb3e94e736b8d8859ce5c81536e91a1033b1d26770c4249000/cryptography-43.0.1-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:68aaecc4178e90719e95298515979814bda0cbada1256a4485414860bd7ab962", size = 3981827 }, - { url = "https://files.pythonhosted.org/packages/ce/dc/1471d4d56608e1013237af334b8a4c35d53895694fbb73882d1c4fd3f55e/cryptography-43.0.1-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:de41fd81a41e53267cb020bb3a7212861da53a7d39f863585d13ea11049cf277", size = 3780034 }, - { url = "https://files.pythonhosted.org/packages/ad/43/7a9920135b0d5437cc2f8f529fa757431eb6a7736ddfadfdee1cc5890800/cryptography-43.0.1-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f98bf604c82c416bc829e490c700ca1553eafdf2912a91e23a79d97d9801372a", size = 3993407 }, - { url = "https://files.pythonhosted.org/packages/cc/42/9ab8467af6c0b76f3d9b8f01d1cf25b9c9f3f2151f4acfab888d21c55a72/cryptography-43.0.1-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:61ec41068b7b74268fa86e3e9e12b9f0c21fcf65434571dbb13d954bceb08042", size = 3886457 }, - { url = "https://files.pythonhosted.org/packages/a4/65/430509e31700286ec02868a2457d2111d03ccefc20349d24e58d171ae0a7/cryptography-43.0.1-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:014f58110f53237ace6a408b5beb6c427b64e084eb451ef25a28308270086494", size = 4081499 }, - { url = "https://files.pythonhosted.org/packages/bb/18/a04b6467e6e09df8c73b91dcee8878f4a438a43a3603dc3cd6f8003b92d8/cryptography-43.0.1-cp37-abi3-win32.whl", hash = "sha256:2bd51274dcd59f09dd952afb696bf9c61a7a49dfc764c04dd33ef7a6b502a1e2", size = 2616504 }, - { url = "https://files.pythonhosted.org/packages/cc/73/0eacbdc437202edcbdc07f3576ed8fb8b0ab79d27bf2c5d822d758a72faa/cryptography-43.0.1-cp37-abi3-win_amd64.whl", hash = "sha256:666ae11966643886c2987b3b721899d250855718d6d9ce41b521252a17985f4d", size = 3067456 }, - { url = "https://files.pythonhosted.org/packages/8a/b6/bc54b371f02cffd35ff8dc6baba88304d7cf8e83632566b4b42e00383e03/cryptography-43.0.1-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:ac119bb76b9faa00f48128b7f5679e1d8d437365c5d26f1c2c3f0da4ce1b553d", size = 6225263 }, - { url = "https://files.pythonhosted.org/packages/00/0e/8217e348a1fa417ec4c78cd3cdf24154f5e76fd7597343a35bd403650dfd/cryptography-43.0.1-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1bbcce1a551e262dfbafb6e6252f1ae36a248e615ca44ba302df077a846a8806", size = 3794368 }, - { url = "https://files.pythonhosted.org/packages/3d/ed/38b6be7254d8f7251fde8054af597ee8afa14f911da67a9410a45f602fc3/cryptography-43.0.1-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58d4e9129985185a06d849aa6df265bdd5a74ca6e1b736a77959b498e0505b85", size = 3981750 }, - { url = "https://files.pythonhosted.org/packages/64/f3/b7946c3887cf7436f002f4cbb1e6aec77b8d299b86be48eeadfefb937c4b/cryptography-43.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d03a475165f3134f773d1388aeb19c2d25ba88b6a9733c5c590b9ff7bbfa2e0c", size = 3778925 }, - { url = "https://files.pythonhosted.org/packages/ac/7e/ebda4dd4ae098a0990753efbb4b50954f1d03003846b943ea85070782da7/cryptography-43.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:511f4273808ab590912a93ddb4e3914dfd8a388fed883361b02dea3791f292e1", size = 3993152 }, - { url = "https://files.pythonhosted.org/packages/43/f6/feebbd78a3e341e3913846a3bb2c29d0b09b1b3af1573c6baabc2533e147/cryptography-43.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:80eda8b3e173f0f247f711eef62be51b599b5d425c429b5d4ca6a05e9e856baa", size = 3886392 }, - { url = "https://files.pythonhosted.org/packages/bd/4c/ab0b9407d5247576290b4fd8abd06b7f51bd414f04eef0f2800675512d61/cryptography-43.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:38926c50cff6f533f8a2dae3d7f19541432610d114a70808f0926d5aaa7121e4", size = 4082606 }, - { url = "https://files.pythonhosted.org/packages/05/36/e532a671998d6fcfdb9122da16434347a58a6bae9465e527e450e0bc60a5/cryptography-43.0.1-cp39-abi3-win32.whl", hash = "sha256:a575913fb06e05e6b4b814d7f7468c2c660e8bb16d8d5a1faf9b33ccc569dd47", size = 2617948 }, - { url = "https://files.pythonhosted.org/packages/b3/c6/c09cee6968add5ff868525c3815e5dccc0e3c6e89eec58dc9135d3c40e88/cryptography-43.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:d75601ad10b059ec832e78823b348bfa1a59f6b8d545db3a24fd44362a1564cb", size = 3070445 }, -] - [[package]] name = "distlib" version = "0.3.9" @@ -190,60 +163,57 @@ wheels = [ [[package]] name = "django" -version = "3.2.25" +version = "5.2.8" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "asgiref" }, - { name = "pytz" }, { name = "sqlparse" }, + { name = "tzdata", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ec/68/0e744f07b57bfdf99abbb6b3eb14fcba188867021c05f4a104e04f6d56b8/Django-3.2.25.tar.gz", hash = "sha256:7ca38a78654aee72378594d63e51636c04b8e28574f5505dff630895b5472777", size = 9836336 } +sdist = { url = "https://files.pythonhosted.org/packages/05/a2/933dbbb3dd9990494960f6e64aca2af4c0745b63b7113f59a822df92329e/django-5.2.8.tar.gz", hash = "sha256:23254866a5bb9a2cfa6004e8b809ec6246eba4b58a7589bc2772f1bcc8456c7f", size = 10849032 } wheels = [ - { url = "https://files.pythonhosted.org/packages/30/8e/cc23c762c5dcd1d367d73cf006a326e0df2bd0e785cba18b658b39904c1e/Django-3.2.25-py3-none-any.whl", hash = "sha256:a52ea7fcf280b16f7b739cec38fa6d3f8953a5456986944c3ca97e79882b4e38", size = 7890550 }, + { url = "https://files.pythonhosted.org/packages/5e/3d/a035a4ee9b1d4d4beee2ae6e8e12fe6dee5514b21f62504e22efcbd9fb46/django-5.2.8-py3-none-any.whl", hash = "sha256:37e687f7bd73ddf043e2b6b97cfe02fcbb11f2dbb3adccc6a2b18c6daa054d7f", size = 8289692 }, ] [[package]] name = "django-post-office" -version = "3.8.0" +version = "3.10.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "bleach", extra = ["css"] }, { name = "django" }, - { name = "pytz" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/0e/57/3c29d7c8205c53b4f950897f53032bf14364386d9d8c3297670cefb9cbd0/django-post_office-3.8.0.tar.gz", hash = "sha256:0df8a3595d8b6088a933d66d984787172e75dd009fa57bb439c6587ea5746df4", size = 63287 } +sdist = { url = "https://files.pythonhosted.org/packages/0f/42/8ab42ba3524cf7989806471095848daa786dd4ba47a28e15694b5d151959/django_post_office-3.10.1.tar.gz", hash = "sha256:739a218cdf3d61968416e896e7998f2e7a88a01fc06f51b8e8d1ba9bd5392acb", size = 52983 } wheels = [ - { url = "https://files.pythonhosted.org/packages/28/f2/2940f3d705b97275cbb2946bf2b641e176d19986353ed4bda06272d1384f/django_post_office-3.8.0-py3-none-any.whl", hash = "sha256:21ae03dd6d09036ae96469c5418305aa39855985f29b21374c7a013b2ff47098", size = 85235 }, + { url = "https://files.pythonhosted.org/packages/9d/1b/aa153f1338c06319e4a262a47d2bd41e7da993e4db235eb420bfe66cf659/django_post_office-3.10.1-py3-none-any.whl", hash = "sha256:372e7b5e28c1fc44ab226f557845cf72d85e705d6124dd4c096a9f85721352f9", size = 75742 }, ] [[package]] name = "django-rest-knox" -version = "4.2.0" +version = "5.0.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cryptography" }, { name = "django" }, { name = "djangorestframework" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/58/29/c6eb00493cc4d36443f66b91d67af65b173230d8250088dd5cc08c65b163/django-rest-knox-4.2.0.tar.gz", hash = "sha256:4595f1dc23d6e41af7939e5f2d8fdaf6ade0a74a656218e7b56683db5566fcc9", size = 11939 } +sdist = { url = "https://files.pythonhosted.org/packages/ba/7e/6ef520fbfa0cb902fe32c6f921426b1dcfa50cb2471a0ddca31ba770fa72/django_rest_knox-5.0.2.tar.gz", hash = "sha256:f283622bcf5d28a6a0203845c065d06c7432efa54399ae32070c61ac03af2d6f", size = 16292 } wheels = [ - { url = "https://files.pythonhosted.org/packages/6d/8a/4caa94433d4fb3031aca3e918b7217cebb94792b841ca6d23d1a4d828e23/django_rest_knox-4.2.0-py3-none-any.whl", hash = "sha256:62b8e374a44cd4e9617eaefe27c915b301bf224fa6550633d3013d3f9f415113", size = 13775 }, + { url = "https://files.pythonhosted.org/packages/13/29/09468c086fd19f34b00a9698ee9cc1864dfae8c171be140657b965577472/django_rest_knox-5.0.2-py3-none-any.whl", hash = "sha256:694da5d0ad6eb3edbfd7cdc8d69c089fc074e6b0e548e00ff2750bf2fdfadb6f", size = 14960 }, ] [[package]] name = "django-stubs" -version = "4.2.7" +version = "5.2.7" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "django" }, { name = "django-stubs-ext" }, - { name = "types-pytz" }, { name = "types-pyyaml" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1c/1f/1b1da357d37cbbf21496c17aad5a55416c7c37944a909c4a19ddd61994df/django-stubs-4.2.7.tar.gz", hash = "sha256:8ccd2ff4ee5adf22b9e3b7b1a516d2e1c2191e9d94e672c35cc2bc3dd61e0f6b", size = 258590 } +sdist = { url = "https://files.pythonhosted.org/packages/5d/a8/bc8c55212978f1e666486b60a4bfb0bc3a066de8212fa7389ff0f3dca639/django_stubs-5.2.7.tar.gz", hash = "sha256:2a07e47a8a867836a763c6bba8bf3775847b4fd9555bfa940360e32d0ee384a1", size = 257339 } wheels = [ - { url = "https://files.pythonhosted.org/packages/31/33/a4024662b31999841bc18ef845a790f6b6a9ad50f5b2aceb029896612d17/django_stubs-4.2.7-py3-none-any.whl", hash = "sha256:4cf4de258fa71adc6f2799e983091b9d46cfc67c6eebc68fe111218c9a62b3b8", size = 456669 }, + { url = "https://files.pythonhosted.org/packages/ad/66/1c8063eee88a943f01d073dbbbda34ed093bf6e19738178506a66abbd5ad/django_stubs-5.2.7-py3-none-any.whl", hash = "sha256:2864e74b56ead866ff1365a051f24d852f6ed02238959664f558a6c9601c95bf", size = 507733 }, ] [package.optional-dependencies] @@ -253,33 +223,32 @@ compatible-mypy = [ [[package]] name = "django-stubs-ext" -version = "5.1.0" +version = "5.2.7" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "django" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/60/a5/dfb120bf3ce1f0da109481605f704ffe72533f056f42e8cffd5a486504a7/django_stubs_ext-5.1.0.tar.gz", hash = "sha256:ed7d51c0b731651879fc75f331fb0806d98b67bfab464e96e2724db6b46ef926", size = 9491 } +sdist = { url = "https://files.pythonhosted.org/packages/9b/6f/a0bab0e6a7676ab3ca02d51b459444e9bd6dd747e3a43b9c24cae6d0a1c6/django_stubs_ext-5.2.7.tar.gz", hash = "sha256:b690655bd4cb8a44ae57abb314e0995dc90414280db8f26fff0cb9fb367d1cac", size = 6524 } wheels = [ - { url = "https://files.pythonhosted.org/packages/eb/03/20a5a19d1b8d26eddd8420261304ee9e6accd802f5332e360daaa2202afb/django_stubs_ext-5.1.0-py3-none-any.whl", hash = "sha256:a455fc222c90b30b29ad8c53319559f5b54a99b4197205ddbb385aede03b395d", size = 8966 }, + { url = "https://files.pythonhosted.org/packages/f8/c9/60445606e26706d3fccadf3b80ee1a9f32c1012683ff2ada7580937b2da9/django_stubs_ext-5.2.7-py3-none-any.whl", hash = "sha256:0466a7132587d49c5bbe12082ac9824d117a0dedcad5d0ada75a6e0d3aca6f60", size = 9979 }, ] [[package]] name = "djangorestframework" -version = "3.14.0" +version = "3.16.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "django" }, - { name = "pytz" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/8e/53/5b2a002c5ebafd60dff1e1945a7d63dee40155830997439a9ba324f0fd50/djangorestframework-3.14.0.tar.gz", hash = "sha256:579a333e6256b09489cbe0a067e66abe55c6595d8926be6b99423786334350c8", size = 1055343 } +sdist = { url = "https://files.pythonhosted.org/packages/8a/95/5376fe618646fde6899b3cdc85fd959716bb67542e273a76a80d9f326f27/djangorestframework-3.16.1.tar.gz", hash = "sha256:166809528b1aced0a17dc66c24492af18049f2c9420dbd0be29422029cfc3ff7", size = 1089735 } wheels = [ - { url = "https://files.pythonhosted.org/packages/ff/4b/3b46c0914ba4b7546a758c35fdfa8e7f017fcbe7f23c878239e93623337a/djangorestframework-3.14.0-py3-none-any.whl", hash = "sha256:eb63f58c9f218e1a7d064d17a70751f528ed4e1d35547fdade9aaf4cd103fd08", size = 1062761 }, + { url = "https://files.pythonhosted.org/packages/b0/ce/bf8b9d3f415be4ac5588545b5fcdbbb841977db1c1d923f7568eeabe1689/djangorestframework-3.16.1-py3-none-any.whl", hash = "sha256:33a59f47fb9c85ede792cbf88bde71893bcda0667bc573f784649521f1102cec", size = 1080442 }, ] [[package]] name = "djangorestframework-stubs" -version = "3.14.5" +version = "3.16.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "django-stubs" }, @@ -288,9 +257,9 @@ dependencies = [ { name = "types-requests" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/cc/51/ef2f2196e81c261490e86c15b78f5a097d825b58f5ceb615bcece612eeb9/djangorestframework-stubs-3.14.5.tar.gz", hash = "sha256:5dd6f638aa5291fb7863e6166128a6ed20bf4986e2fc5cf334e6afc841797a09", size = 34281 } +sdist = { url = "https://files.pythonhosted.org/packages/4a/81/10d0139796e3df5c48e9383ef73e3a999351415e94cc427a4546f79bad07/djangorestframework_stubs-3.16.5.tar.gz", hash = "sha256:1de13fad78b7de3f7ee34dcc1864929adc658342508567263eaff96a4b32312e", size = 32238 } wheels = [ - { url = "https://files.pythonhosted.org/packages/13/05/c98a1e3d3824dc9a330df26827935b5d7559a5c01bcfca8e18ab645dc758/djangorestframework_stubs-3.14.5-py3-none-any.whl", hash = "sha256:43d788fd50cda49b922cd411e59c5b8cdc3f3de49c02febae12ce42139f0269b", size = 53481 }, + { url = "https://files.pythonhosted.org/packages/93/33/9c8d203d9e409736665e9f4ed2b9986dfb77774c7d17dab392ed498cb201/djangorestframework_stubs-3.16.5-py3-none-any.whl", hash = "sha256:aebd45bc2cf5e52b90651c548ad25c66d5d8d2b11c48147cbaba87498eefd211", size = 56506 }, ] [package.optional-dependencies] @@ -301,7 +270,7 @@ compatible-mypy = [ [[package]] name = "drf-spectacular" -version = "0.27.2" +version = "0.29.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "django" }, @@ -311,9 +280,9 @@ dependencies = [ { name = "pyyaml" }, { name = "uritemplate" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b1/c9/55311dcbdae9a437eeb8f898f8421a6a3eabf08725c23ddf458cf2479b78/drf-spectacular-0.27.2.tar.gz", hash = "sha256:a199492f2163c4101055075ebdbb037d59c6e0030692fc83a1a8c0fc65929981", size = 235131 } +sdist = { url = "https://files.pythonhosted.org/packages/5e/0e/a4f50d83e76cbe797eda88fc0083c8ca970cfa362b5586359ef06ec6f70a/drf_spectacular-0.29.0.tar.gz", hash = "sha256:0a069339ea390ce7f14a75e8b5af4a0860a46e833fd4af027411a3e94fc1a0cc", size = 241722 } wheels = [ - { url = "https://files.pythonhosted.org/packages/b2/cd/84c44a5d435f6544e58a9b138305f59bca232157ae4ecb658f9787f87d1c/drf_spectacular-0.27.2-py3-none-any.whl", hash = "sha256:b1c04bf8b2fbbeaf6f59414b4ea448c8787aba4d32f76055c3b13335cf7ec37b", size = 102930 }, + { url = "https://files.pythonhosted.org/packages/32/d9/502c56fc3ca960075d00956283f1c44e8cafe433dada03f9ed2821f3073b/drf_spectacular-0.29.0-py3-none-any.whl", hash = "sha256:d1ee7c9535d89848affb4427347f7c4a22c5d22530b8842ef133d7b72e19b41a", size = 105433 }, ] [[package]] @@ -327,14 +296,14 @@ wheels = [ [[package]] name = "gunicorn" -version = "20.1.0" +version = "23.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "setuptools" }, + { name = "packaging" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/28/5b/0d1f0296485a6af03366604142ea8f19f0833894db3512a40ed07b2a56dd/gunicorn-20.1.0.tar.gz", hash = "sha256:e0a968b5ba15f8a328fdfd7ab1fcb5af4470c28aaf7e55df02a99bc13138e6e8", size = 370601 } +sdist = { url = "https://files.pythonhosted.org/packages/34/72/9614c465dc206155d93eff0ca20d42e1e35afc533971379482de953521a4/gunicorn-23.0.0.tar.gz", hash = "sha256:f014447a0101dc57e294f6c18ca6b40227a4c90e9bdb586042628030cba004ec", size = 375031 } wheels = [ - { url = "https://files.pythonhosted.org/packages/e4/dd/5b190393e6066286773a67dfcc2f9492058e9b57c4867a95f1ba5caf0a83/gunicorn-20.1.0-py3-none-any.whl", hash = "sha256:9dcc4547dbb1cb284accfb15ab5667a0e5d1881cc443e0677b4882a4067a807e", size = 79531 }, + { url = "https://files.pythonhosted.org/packages/cb/7d/6dac2a6e1eba33ee43f318edbed4ff29151a49b5d37f080aad1e6469bca4/gunicorn-23.0.0-py3-none-any.whl", hash = "sha256:ec400d38950de4dfd418cff8328b2c8faed0edb0d517d3394e457c317908ca4d", size = 85029 }, ] [[package]] @@ -393,20 +362,22 @@ wheels = [ [[package]] name = "mypy" -version = "1.7.1" +version = "1.18.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "mypy-extensions" }, + { name = "pathspec" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ae/30/05a7c016431b3fdbaf0bcf663aee7c5e4b3d2293cd4e0568140cecae4967/mypy-1.7.1.tar.gz", hash = "sha256:fcb6d9afb1b6208b4c712af0dafdc650f518836065df0d4fb1d800f5d6773db2", size = 2979742 } +sdist = { url = "https://files.pythonhosted.org/packages/c0/77/8f0d0001ffad290cef2f7f216f96c814866248a0b92a722365ed54648e7e/mypy-1.18.2.tar.gz", hash = "sha256:06a398102a5f203d7477b2923dda3634c36727fa5c237d8f859ef90c42a9924b", size = 3448846 } wheels = [ - { url = "https://files.pythonhosted.org/packages/88/43/2a04b1cc1a27b1fe623cae9c6840f333e47422598a3556d3824a07d8c2c4/mypy-1.7.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:6dbdec441c60699288adf051f51a5d512b0d818526d1dcfff5a41f8cd8b4aaf1", size = 10817552 }, - { url = "https://files.pythonhosted.org/packages/cc/b7/5ae852edccf9eba4d71b3df212928f15759142894a4bb84c1d3cb0ade729/mypy-1.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4fc3d14ee80cd22367caaaf6e014494415bf440980a3045bf5045b525680ac33", size = 9786567 }, - { url = "https://files.pythonhosted.org/packages/b6/ab/39f476f18a45b2a74b38722107ac5c8e50a5ee41f39f7ac46ac50caa630e/mypy-1.7.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2c6e4464ed5f01dc44dc9821caf67b60a4e5c3b04278286a85c067010653a0eb", size = 12389281 }, - { url = "https://files.pythonhosted.org/packages/83/9c/51effe3396740868649364ebea5001c694af29ec891bc90aae6944886c45/mypy-1.7.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:d9b338c19fa2412f76e17525c1b4f2c687a55b156320acb588df79f2e6fa9fea", size = 12472382 }, - { url = "https://files.pythonhosted.org/packages/bd/7a/34e1ec6e515048ad7976c2afaa59c056fe1f942a1a2ae3704c754e3c0248/mypy-1.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:204e0d6de5fd2317394a4eff62065614c4892d5a4d1a7ee55b765d7a3d9e3f82", size = 9281023 }, - { url = "https://files.pythonhosted.org/packages/10/df/92bb67911c6c1d3faa46e4c9a5d0a93dd343dcf56022d1fb97a0c0ee65eb/mypy-1.7.1-py3-none-any.whl", hash = "sha256:f7c5d642db47376a0cc130f0de6d055056e010debdaf0707cd2b0fc7e7ef30ea", size = 2542516 }, + { url = "https://files.pythonhosted.org/packages/07/06/dfdd2bc60c66611dd8335f463818514733bc763e4760dee289dcc33df709/mypy-1.18.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:33eca32dd124b29400c31d7cf784e795b050ace0e1f91b8dc035672725617e34", size = 12908273 }, + { url = "https://files.pythonhosted.org/packages/81/14/6a9de6d13a122d5608e1a04130724caf9170333ac5a924e10f670687d3eb/mypy-1.18.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a3c47adf30d65e89b2dcd2fa32f3aeb5e94ca970d2c15fcb25e297871c8e4764", size = 11920910 }, + { url = "https://files.pythonhosted.org/packages/5f/a9/b29de53e42f18e8cc547e38daa9dfa132ffdc64f7250e353f5c8cdd44bee/mypy-1.18.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d6c838e831a062f5f29d11c9057c6009f60cb294fea33a98422688181fe2893", size = 12465585 }, + { url = "https://files.pythonhosted.org/packages/77/ae/6c3d2c7c61ff21f2bee938c917616c92ebf852f015fb55917fd6e2811db2/mypy-1.18.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01199871b6110a2ce984bde85acd481232d17413868c9807e95c1b0739a58914", size = 13348562 }, + { url = "https://files.pythonhosted.org/packages/4d/31/aec68ab3b4aebdf8f36d191b0685d99faa899ab990753ca0fee60fb99511/mypy-1.18.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a2afc0fa0b0e91b4599ddfe0f91e2c26c2b5a5ab263737e998d6817874c5f7c8", size = 13533296 }, + { url = "https://files.pythonhosted.org/packages/9f/83/abcb3ad9478fca3ebeb6a5358bb0b22c95ea42b43b7789c7fb1297ca44f4/mypy-1.18.2-cp312-cp312-win_amd64.whl", hash = "sha256:d8068d0afe682c7c4897c0f7ce84ea77f6de953262b12d07038f4d296d547074", size = 9828828 }, + { url = "https://files.pythonhosted.org/packages/87/e3/be76d87158ebafa0309946c4a73831974d4d6ab4f4ef40c3b53a385a66fd/mypy-1.18.2-py3-none-any.whl", hash = "sha256:22a1748707dd62b58d2ae53562ffc4d7f8bcc727e8ac7cbc69c053ddc874d47e", size = 2352367 }, ] [[package]] @@ -427,6 +398,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314 }, ] +[[package]] +name = "packaging" +version = "25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469 }, +] + +[[package]] +name = "pathspec" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712", size = 51043 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191 }, +] + [[package]] name = "platformdirs" version = "4.3.6" @@ -438,7 +427,7 @@ wheels = [ [[package]] name = "pre-commit" -version = "4.0.1" +version = "4.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cfgv" }, @@ -447,9 +436,9 @@ dependencies = [ { name = "pyyaml" }, { name = "virtualenv" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2e/c8/e22c292035f1bac8b9f5237a2622305bc0304e776080b246f3df57c4ff9f/pre_commit-4.0.1.tar.gz", hash = "sha256:80905ac375958c0444c65e9cebebd948b3cdb518f335a091a670a89d652139d2", size = 191678 } +sdist = { url = "https://files.pythonhosted.org/packages/a6/49/7845c2d7bf6474efd8e27905b51b11e6ce411708c91e829b93f324de9929/pre_commit-4.4.0.tar.gz", hash = "sha256:f0233ebab440e9f17cabbb558706eb173d19ace965c68cdce2c081042b4fab15", size = 197501 } wheels = [ - { url = "https://files.pythonhosted.org/packages/16/8f/496e10d51edd6671ebe0432e33ff800aa86775d2d147ce7d43389324a525/pre_commit-4.0.1-py2.py3-none-any.whl", hash = "sha256:efde913840816312445dc98787724647c65473daefe420785f885e8ed9a06878", size = 218713 }, + { url = "https://files.pythonhosted.org/packages/27/11/574fe7d13acf30bfd0a8dd7fa1647040f2b8064f13f43e8c963b1e65093b/pre_commit-4.4.0-py2.py3-none-any.whl", hash = "sha256:b35ea52957cbf83dcc5d8ee636cbead8624e3a15fbfa61a370e42158ac8a5813", size = 226049 }, ] [[package]] @@ -472,15 +461,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/92/29/06261ea000e2dc1e22907dbbc483a1093665509ea586b29b8986a0e56733/psycopg2_binary-2.9.10-cp312-cp312-win_amd64.whl", hash = "sha256:18c5ee682b9c6dd3696dad6e54cc7ff3a1a9020df6a5c0f861ef8bfd338c3ca0", size = 1164031 }, ] -[[package]] -name = "pycparser" -version = "2.22" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1d/b2/31537cf4b1ca988837256c910a668b553fceb8f069bedc4b1c826024b52c/pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6", size = 172736 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/13/a3/a812df4e2dd5696d1f351d58b8fe16a405b234ad2886a0dab9183fb78109/pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc", size = 117552 }, -] - [[package]] name = "pymemcache" version = "4.0.0" @@ -492,20 +472,11 @@ wheels = [ [[package]] name = "python-dotenv" -version = "0.19.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/49/62/4f25667e10561303a34cb89e3187c35985c0889b99f6f1468aaf17fbb03e/python-dotenv-0.19.2.tar.gz", hash = "sha256:a5de49a31e953b45ff2d2fd434bbc2670e8db5273606c1e737cc6b93eff3655f", size = 31508 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0e/f1/0317f4b2c5284075a2154fe95539b43c0acecbcb86fe80fcb2645803edd9/python_dotenv-0.19.2-py2.py3-none-any.whl", hash = "sha256:32b2bdc1873fd3a3c346da1c6db83d0053c3c62f28f1f38516070c4c8971b1d3", size = 17674 }, -] - -[[package]] -name = "pytz" -version = "2024.2" +version = "1.2.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/3a/31/3c70bf7603cc2dca0f19bdc53b4537a797747a58875b552c8c413d963a3f/pytz-2024.2.tar.gz", hash = "sha256:2aa355083c50a0f93fa581709deac0c9ad65cca8a9e9beac660adcbd493c798a", size = 319692 } +sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221 } wheels = [ - { url = "https://files.pythonhosted.org/packages/11/c3/005fcca25ce078d2cc29fd559379817424e94885510568bc1bc53d7d5846/pytz-2024.2-py2.py3-none-any.whl", hash = "sha256:31c7c1817eb7fae7ca4b8c7ee50c72f93aa2dd863de768e1ef4245d426aa0725", size = 508002 }, + { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230 }, ] [[package]] @@ -576,36 +547,28 @@ wheels = [ [[package]] name = "ruff" -version = "0.7.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/2c/c7/f3367d1da5d568192968c5c9e7f3d51fb317b9ac04828493b23d8fce8ce6/ruff-0.7.0.tar.gz", hash = "sha256:47a86360cf62d9cd53ebfb0b5eb0e882193fc191c6d717e8bef4462bc3b9ea2b", size = 3146645 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/48/59/a0275a0913f3539498d116046dd679cd657fe3b7caf5afe1733319414932/ruff-0.7.0-py3-none-linux_armv6l.whl", hash = "sha256:0cdf20c2b6ff98e37df47b2b0bd3a34aaa155f59a11182c1303cce79be715628", size = 10434007 }, - { url = "https://files.pythonhosted.org/packages/cd/94/da0ba5f956d04c90dd899209904210600009dcda039ce840d83eb4298c7d/ruff-0.7.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:496494d350c7fdeb36ca4ef1c9f21d80d182423718782222c29b3e72b3512737", size = 10048066 }, - { url = "https://files.pythonhosted.org/packages/57/1d/e5cc149ecc46e4f203403a79ccd170fad52d316f98b87d0f63b1945567db/ruff-0.7.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:214b88498684e20b6b2b8852c01d50f0651f3cc6118dfa113b4def9f14faaf06", size = 9711389 }, - { url = "https://files.pythonhosted.org/packages/05/67/fb7ea2c869c539725a16c5bc294e9aa34f8b1b6fe702f1d173a5da517c2b/ruff-0.7.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:630fce3fefe9844e91ea5bbf7ceadab4f9981f42b704fae011bb8efcaf5d84be", size = 10755174 }, - { url = "https://files.pythonhosted.org/packages/5f/f0/13703bc50536a0613ea3dce991116e5f0917a1f05528c6ab738b33c08d3f/ruff-0.7.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:211d877674e9373d4bb0f1c80f97a0201c61bcd1e9d045b6e9726adc42c156aa", size = 10196040 }, - { url = "https://files.pythonhosted.org/packages/99/c1/77b04ab20324ab03d333522ee55fb0f1c38e3ca0d326b4905f82ce6b6c70/ruff-0.7.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:194d6c46c98c73949a106425ed40a576f52291c12bc21399eb8f13a0f7073495", size = 11033684 }, - { url = "https://files.pythonhosted.org/packages/f2/97/f463334dc4efeea3551cd109163df15561c18a1c3ec13d51643740fd36ba/ruff-0.7.0-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:82c2579b82b9973a110fab281860403b397c08c403de92de19568f32f7178598", size = 11803700 }, - { url = "https://files.pythonhosted.org/packages/b4/f8/a31d40c4bb92933d376a53e7c5d0245d9b27841357e4820e96d38f54b480/ruff-0.7.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9af971fe85dcd5eaed8f585ddbc6bdbe8c217fb8fcf510ea6bca5bdfff56040e", size = 11347848 }, - { url = "https://files.pythonhosted.org/packages/83/62/0c133b35ddaf91c65c30a56718b80bdef36bfffc35684d29e3a4878e0ea3/ruff-0.7.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b641c7f16939b7d24b7bfc0be4102c56562a18281f84f635604e8a6989948914", size = 12480632 }, - { url = "https://files.pythonhosted.org/packages/46/96/464058dd1d980014fb5aa0a1254e78799efb3096fc7a4823cd66a1621276/ruff-0.7.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d71672336e46b34e0c90a790afeac8a31954fd42872c1f6adaea1dff76fd44f9", size = 10941919 }, - { url = "https://files.pythonhosted.org/packages/a0/f7/bda37ec77986a435dde44e1f59374aebf4282a5fa9cf17735315b847141f/ruff-0.7.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:ab7d98c7eed355166f367597e513a6c82408df4181a937628dbec79abb2a1fe4", size = 10745519 }, - { url = "https://files.pythonhosted.org/packages/c2/33/5f77fc317027c057b61a848020a47442a1cbf12e592df0e41e21f4d0f3bd/ruff-0.7.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:1eb54986f770f49edb14f71d33312d79e00e629a57387382200b1ef12d6a4ef9", size = 10284872 }, - { url = "https://files.pythonhosted.org/packages/ff/50/98aec292bc9537f640b8d031c55f3414bf15b6ed13b3e943fed75ac927b9/ruff-0.7.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:dc452ba6f2bb9cf8726a84aa877061a2462afe9ae0ea1d411c53d226661c601d", size = 10600334 }, - { url = "https://files.pythonhosted.org/packages/f2/85/12607ae3201423a179b8cfadc7cb1e57d02cd0135e45bd0445acb4cef327/ruff-0.7.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:4b406c2dce5be9bad59f2de26139a86017a517e6bcd2688da515481c05a2cb11", size = 11017333 }, - { url = "https://files.pythonhosted.org/packages/d4/7f/3b85a56879e705d5f46ec14daf8a439fca05c3081720fe3dc3209100922d/ruff-0.7.0-py3-none-win32.whl", hash = "sha256:f6c968509f767776f524a8430426539587d5ec5c662f6addb6aa25bc2e8195ec", size = 8570962 }, - { url = "https://files.pythonhosted.org/packages/39/9f/c5ee2b40d377354dabcc23cff47eb299de4b4d06d345068f8f8cc1eadac8/ruff-0.7.0-py3-none-win_amd64.whl", hash = "sha256:ff4aabfbaaba880e85d394603b9e75d32b0693152e16fa659a3064a85df7fce2", size = 9365544 }, - { url = "https://files.pythonhosted.org/packages/89/8b/ee1509f60148cecba644aa718f6633216784302458340311898aaf0b1bed/ruff-0.7.0-py3-none-win_arm64.whl", hash = "sha256:10842f69c245e78d6adec7e1db0a7d9ddc2fff0621d730e61657b64fa36f207e", size = 8695763 }, -] - -[[package]] -name = "setuptools" -version = "75.2.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/07/37/b31be7e4b9f13b59cde9dcaeff112d401d49e0dc5b37ed4a9fc8fb12f409/setuptools-75.2.0.tar.gz", hash = "sha256:753bb6ebf1f465a1912e19ed1d41f403a79173a9acf66a42e7e6aec45c3c16ec", size = 1350308 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/31/2d/90165d51ecd38f9a02c6832198c13a4e48652485e2ccf863ebb942c531b6/setuptools-75.2.0-py3-none-any.whl", hash = "sha256:a7fcb66f68b4d9e8e66b42f9876150a3371558f98fa32222ffaa5bced76406f8", size = 1249825 }, +version = "0.14.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/fa/fbb67a5780ae0f704876cb8ac92d6d76da41da4dc72b7ed3565ab18f2f52/ruff-0.14.5.tar.gz", hash = "sha256:8d3b48d7d8aad423d3137af7ab6c8b1e38e4de104800f0d596990f6ada1a9fc1", size = 5615944 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/31/c07e9c535248d10836a94e4f4e8c5a31a1beed6f169b31405b227872d4f4/ruff-0.14.5-py3-none-linux_armv6l.whl", hash = "sha256:f3b8248123b586de44a8018bcc9fefe31d23dda57a34e6f0e1e53bd51fd63594", size = 13171630 }, + { url = "https://files.pythonhosted.org/packages/8e/5c/283c62516dca697cd604c2796d1487396b7a436b2f0ecc3fd412aca470e0/ruff-0.14.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:f7a75236570318c7a30edd7f5491945f0169de738d945ca8784500b517163a72", size = 13413925 }, + { url = "https://files.pythonhosted.org/packages/b6/f3/aa319f4afc22cb6fcba2b9cdfc0f03bbf747e59ab7a8c5e90173857a1361/ruff-0.14.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:6d146132d1ee115f8802356a2dc9a634dbf58184c51bff21f313e8cd1c74899a", size = 12574040 }, + { url = "https://files.pythonhosted.org/packages/f9/7f/cb5845fcc7c7e88ed57f58670189fc2ff517fe2134c3821e77e29fd3b0c8/ruff-0.14.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e2380596653dcd20b057794d55681571a257a42327da8894b93bbd6111aa801f", size = 13009755 }, + { url = "https://files.pythonhosted.org/packages/21/d2/bcbedbb6bcb9253085981730687ddc0cc7b2e18e8dc13cf4453de905d7a0/ruff-0.14.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2d1fa985a42b1f075a098fa1ab9d472b712bdb17ad87a8ec86e45e7fa6273e68", size = 12937641 }, + { url = "https://files.pythonhosted.org/packages/a4/58/e25de28a572bdd60ffc6bb71fc7fd25a94ec6a076942e372437649cbb02a/ruff-0.14.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88f0770d42b7fa02bbefddde15d235ca3aa24e2f0137388cc15b2dcbb1f7c7a7", size = 13610854 }, + { url = "https://files.pythonhosted.org/packages/7d/24/43bb3fd23ecee9861970978ea1a7a63e12a204d319248a7e8af539984280/ruff-0.14.5-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:3676cb02b9061fee7294661071c4709fa21419ea9176087cb77e64410926eb78", size = 15061088 }, + { url = "https://files.pythonhosted.org/packages/23/44/a022f288d61c2f8c8645b24c364b719aee293ffc7d633a2ca4d116b9c716/ruff-0.14.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b595bedf6bc9cab647c4a173a61acf4f1ac5f2b545203ba82f30fcb10b0318fb", size = 14734717 }, + { url = "https://files.pythonhosted.org/packages/58/81/5c6ba44de7e44c91f68073e0658109d8373b0590940efe5bd7753a2585a3/ruff-0.14.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f55382725ad0bdb2e8ee2babcbbfb16f124f5a59496a2f6a46f1d9d99d93e6e2", size = 14028812 }, + { url = "https://files.pythonhosted.org/packages/ad/ef/41a8b60f8462cb320f68615b00299ebb12660097c952c600c762078420f8/ruff-0.14.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7497d19dce23976bdaca24345ae131a1d38dcfe1b0850ad8e9e6e4fa321a6e19", size = 13825656 }, + { url = "https://files.pythonhosted.org/packages/7c/00/207e5de737fdb59b39eb1fac806904fe05681981b46d6a6db9468501062e/ruff-0.14.5-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:410e781f1122d6be4f446981dd479470af86537fb0b8857f27a6e872f65a38e4", size = 13959922 }, + { url = "https://files.pythonhosted.org/packages/bc/7e/fa1f5c2776db4be405040293618846a2dece5c70b050874c2d1f10f24776/ruff-0.14.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c01be527ef4c91a6d55e53b337bfe2c0f82af024cc1a33c44792d6844e2331e1", size = 12932501 }, + { url = "https://files.pythonhosted.org/packages/67/d8/d86bf784d693a764b59479a6bbdc9515ae42c340a5dc5ab1dabef847bfaa/ruff-0.14.5-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f66e9bb762e68d66e48550b59c74314168ebb46199886c5c5aa0b0fbcc81b151", size = 12927319 }, + { url = "https://files.pythonhosted.org/packages/ac/de/ee0b304d450ae007ce0cb3e455fe24fbcaaedae4ebaad6c23831c6663651/ruff-0.14.5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:d93be8f1fa01022337f1f8f3bcaa7ffee2d0b03f00922c45c2207954f351f465", size = 13206209 }, + { url = "https://files.pythonhosted.org/packages/33/aa/193ca7e3a92d74f17d9d5771a765965d2cf42c86e6f0fd95b13969115723/ruff-0.14.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:c135d4b681f7401fe0e7312017e41aba9b3160861105726b76cfa14bc25aa367", size = 13953709 }, + { url = "https://files.pythonhosted.org/packages/cc/f1/7119e42aa1d3bf036ffc9478885c2e248812b7de9abea4eae89163d2929d/ruff-0.14.5-py3-none-win32.whl", hash = "sha256:c83642e6fccfb6dea8b785eb9f456800dcd6a63f362238af5fc0c83d027dd08b", size = 12925808 }, + { url = "https://files.pythonhosted.org/packages/3b/9d/7c0a255d21e0912114784e4a96bf62af0618e2190cae468cd82b13625ad2/ruff-0.14.5-py3-none-win_amd64.whl", hash = "sha256:9d55d7af7166f143c94eae1db3312f9ea8f95a4defef1979ed516dbb38c27621", size = 14331546 }, + { url = "https://files.pythonhosted.org/packages/e5/80/69756670caedcf3b9be597a6e12276a6cf6197076eb62aad0c608f8efce0/ruff-0.14.5-py3-none-win_arm64.whl", hash = "sha256:4b700459d4649e2594b31f20a9de33bc7c19976d4746d8d0798ad959621d64a4", size = 13433331 }, ] [[package]] @@ -638,15 +601,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/da/99/fd23634d6962c2791fb8cb6ccae1f05dcbfc39bce36bba8b1c9a8d92eae8/tinycss2-1.2.1-py3-none-any.whl", hash = "sha256:2b80a96d41e7c3914b8cda8bc7f705a4d9c49275616e886103dd839dfc847847", size = 21824 }, ] -[[package]] -name = "types-pytz" -version = "2024.2.0.20241003" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/66/d0/73aa3063a9ef9881bd7103cb4ae379bfd8fafda0e86b01b694d676313a4b/types-pytz-2024.2.0.20241003.tar.gz", hash = "sha256:575dc38f385a922a212bac00a7d6d2e16e141132a3c955078f4a4fd13ed6cb44", size = 5474 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/86/60/2a2977ce0f91255bbb668350b127a801a06ad37c326a2e5bfd52f03e0784/types_pytz-2024.2.0.20241003-py3-none-any.whl", hash = "sha256:3e22df1336c0c6ad1d29163c8fda82736909eb977281cb823c57f8bae07118b7", size = 5245 }, -] - [[package]] name = "types-pyyaml" version = "6.0.12.20240917" @@ -677,6 +631,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/26/9f/ad63fc0248c5379346306f8668cda6e2e2e9c95e01216d2b8ffd9ff037d0/typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", size = 37438 }, ] +[[package]] +name = "tzdata" +version = "2025.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/95/32/1a225d6164441be760d75c2c42e2780dc0873fe382da3e98a2e1e48361e5/tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9", size = 196380 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8", size = 347839 }, +] + [[package]] name = "uritemplate" version = "4.1.1" @@ -720,9 +683,9 @@ wheels = [ [[package]] name = "whitenoise" -version = "6.6.0" +version = "6.11.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/16/e3/adddb43cf8eb924e18eca677d4e40d47348566224b724cb8d1eaf6a48d1b/whitenoise-6.6.0.tar.gz", hash = "sha256:8998f7370973447fac1e8ef6e8ded2c5209a7b1f67c1012866dbcd09681c3251", size = 25078 } +sdist = { url = "https://files.pythonhosted.org/packages/15/95/8c81ec6b6ebcbf8aca2de7603070ccf37dbb873b03f20708e0f7c1664bc6/whitenoise-6.11.0.tar.gz", hash = "sha256:0f5bfce6061ae6611cd9396a8231e088722e4fc67bc13a111be74c738d99375f", size = 26432 } wheels = [ - { url = "https://files.pythonhosted.org/packages/67/16/bb488ac8230f1bce94943b6654f2aad566d18aae575c8b6d8a99c78c489e/whitenoise-6.6.0-py3-none-any.whl", hash = "sha256:b1f9db9bf67dc183484d760b99f4080185633136a273a03f6436034a41064146", size = 19870 }, + { url = "https://files.pythonhosted.org/packages/6c/e9/4366332f9295fe0647d7d3251ce18f5615fbcb12d02c79a26f8dba9221b3/whitenoise-6.11.0-py3-none-any.whl", hash = "sha256:b2aeb45950597236f53b5342b3121c5de69c8da0109362aee506ce88e022d258", size = 20197 }, ] From 35ad8abb460c445c578c4d279d280ef30172493f Mon Sep 17 00:00:00 2001 From: Gilles <43683714+corp-0@users.noreply.github.com> Date: Fri, 14 Nov 2025 01:01:57 -0300 Subject: [PATCH 11/13] feat: add new mail tools for admining (#107) --- src/central_command/settings.py | 1 + src/central_command/urls.py | 1 + src/mail_tools/__init__.py | 1 + src/mail_tools/apps.py | 10 ++ src/mail_tools/forms.py | 52 +++++++ src/mail_tools/registry.py | 92 +++++++++++ .../templates/mail_tools/broadcast.html | 143 ++++++++++++++++++ src/mail_tools/templates/mail_tools/list.html | 39 +++++ src/mail_tools/urls.py | 11 ++ src/mail_tools/views.py | 60 ++++++++ src/templates/base_email.html | 45 ++++++ src/templates/confirm_template.html | 78 ++++------ src/templates/info_template.html | 28 ++++ src/templates/password_reset.html | 77 ++++------ 14 files changed, 544 insertions(+), 94 deletions(-) create mode 100644 src/mail_tools/__init__.py create mode 100644 src/mail_tools/apps.py create mode 100644 src/mail_tools/forms.py create mode 100644 src/mail_tools/registry.py create mode 100644 src/mail_tools/templates/mail_tools/broadcast.html create mode 100644 src/mail_tools/templates/mail_tools/list.html create mode 100644 src/mail_tools/urls.py create mode 100644 src/mail_tools/views.py create mode 100644 src/templates/base_email.html create mode 100644 src/templates/info_template.html diff --git a/src/central_command/settings.py b/src/central_command/settings.py index 91431f4..69cf6c4 100644 --- a/src/central_command/settings.py +++ b/src/central_command/settings.py @@ -53,6 +53,7 @@ "persistence", "baby_serverlist", "drf_spectacular", + "mail_tools", ] # What user model to use for authentication? diff --git a/src/central_command/urls.py b/src/central_command/urls.py index fd3c277..3b5ecb0 100644 --- a/src/central_command/urls.py +++ b/src/central_command/urls.py @@ -26,4 +26,5 @@ path("accounts/", include("accounts.api.urls", "Accounts API")), path("persistence/", include("persistence.api.urls")), path("baby-serverlist/", include("baby_serverlist.api.urls")), + path("mail-tools/", include("mail_tools.urls")), ] diff --git a/src/mail_tools/__init__.py b/src/mail_tools/__init__.py new file mode 100644 index 0000000..ca1706c --- /dev/null +++ b/src/mail_tools/__init__.py @@ -0,0 +1 @@ +default_app_config = "mail_tools.apps.MailToolsConfig" diff --git a/src/mail_tools/apps.py b/src/mail_tools/apps.py new file mode 100644 index 0000000..93ded32 --- /dev/null +++ b/src/mail_tools/apps.py @@ -0,0 +1,10 @@ +from django.apps import AppConfig + + +class MailToolsConfig(AppConfig): + name = "mail_tools" + verbose_name = "Mail Tools" + + def ready(self) -> None: + # Ensure registry entries are loaded on startup. + from . import registry # noqa: F401 diff --git a/src/mail_tools/forms.py b/src/mail_tools/forms.py new file mode 100644 index 0000000..1f3905e --- /dev/null +++ b/src/mail_tools/forms.py @@ -0,0 +1,52 @@ +from collections.abc import Iterable +from typing import cast + +from django import forms + +from accounts.models import Account + +from .registry import TemplatePreview, broadcastable_previews, get_preview + + +def _preview_choices() -> Iterable[tuple[str, str]]: + return [(preview.slug, preview.label) for preview in broadcastable_previews()] + + +class BroadcastEmailForm(forms.Form): + template_slug = forms.ChoiceField(label="Template") + subject = forms.CharField(label="Subject", max_length=120) + body_html = forms.CharField( + label="Body (HTML)", + widget=forms.HiddenInput(), + required=False, + help_text="Optional rich text body injected into the template (used by the info template).", + ) + recipients = forms.ModelMultipleChoiceField( + label="Recipients", + queryset=Account.objects.none(), + widget=forms.SelectMultiple(attrs={"size": 12}), + ) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + template_field = cast(forms.ChoiceField, self.fields["template_slug"]) + template_field.choices = list(_preview_choices()) + recipients_field = cast(forms.ModelMultipleChoiceField, self.fields["recipients"]) + recipients_field.queryset = Account.objects.order_by("email") + self.preview: TemplatePreview | None = None + + def clean_template_slug(self) -> str: + slug = self.cleaned_data["template_slug"] + try: + preview = get_preview(slug) + except LookupError as exc: + raise forms.ValidationError(str(exc)) + if not preview.allow_broadcast: + raise forms.ValidationError("This template cannot be used for manual sending.") + self.preview = preview + return slug + + def get_preview(self) -> TemplatePreview: + if self.preview is None: + raise ValueError("Preview not set. Did you call is_valid()?") + return self.preview diff --git a/src/mail_tools/registry.py b/src/mail_tools/registry.py new file mode 100644 index 0000000..56547b1 --- /dev/null +++ b/src/mail_tools/registry.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +from collections.abc import Iterable +from copy import deepcopy +from dataclasses import dataclass +from typing import Any, Optional + +from django.conf import settings + + +@dataclass(frozen=True) +class TemplatePreview: + slug: str + label: str + template_name: str + context: dict[str, Any] + allow_broadcast: bool = False + + def build_context( + self, + *, + user_name: Optional[str] = None, + overrides: Optional[dict[str, Any]] = None, + ) -> dict[str, Any]: + data = deepcopy(self.context) + if user_name: + data["user_name"] = user_name + if overrides: + data.update({k: v for k, v in overrides.items() if v not in (None, "")}) + return data + + +_registry: dict[str, TemplatePreview] = {} + + +def register(preview: TemplatePreview) -> None: + if preview.slug in _registry: + raise ValueError(f"Duplicate mail preview slug '{preview.slug}'") + _registry[preview.slug] = preview + + +def all_previews() -> Iterable[TemplatePreview]: + return _registry.values() + + +def broadcastable_previews() -> Iterable[TemplatePreview]: + return (preview for preview in _registry.values() if preview.allow_broadcast) + + +def get_preview(slug: str) -> TemplatePreview: + try: + return _registry[slug] + except KeyError as exc: + raise LookupError(f"No mail preview registered for '{slug}'") from exc + + +register( + TemplatePreview( + slug="confirm-account", + label="Account confirmation", + template_name="confirm_template.html", + context={ + "user_name": "Alex Crew", + "link": f"{settings.ACCOUNT_CONFIRMATION_URL}?token=example-token", + }, + ) +) + +register( + TemplatePreview( + slug="password-reset", + label="Password reset", + template_name="password_reset.html", + context={ + "user_name": "Alex Crew", + "link": f"{settings.PASS_RESET_URL}?token=example-token", + }, + ) +) + +register( + TemplatePreview( + slug="info", + label="Informational message", + template_name="info_template.html", + context={ + "user_name": "Alex Crew", + "body_html": "

    We wanted to let you know that scheduled maintenance will occur on Saturday at 18:00 UTC. Servers may be unavailable for roughly 30 minutes.

    Thanks for your patience!

    ", + }, + allow_broadcast=True, + ) +) diff --git a/src/mail_tools/templates/mail_tools/broadcast.html b/src/mail_tools/templates/mail_tools/broadcast.html new file mode 100644 index 0000000..9476164 --- /dev/null +++ b/src/mail_tools/templates/mail_tools/broadcast.html @@ -0,0 +1,143 @@ + + + + + Broadcast email + + + +
    +

    Broadcast email

    +

    Pick a template and send it to selected accounts. Only templates explicitly allowed for broadcasting will appear here.

    +

    ← Back to templates

    + +
    + {% csrf_token %} +
    + {{ form.template_slug.label_tag }} + {{ form.template_slug }} + {{ form.template_slug.errors }} +
    + +
    + {{ form.subject.label_tag }} + {{ form.subject }} + {{ form.subject.errors }} +
    + +
    + + +
    + + + + + +
    +
    + {{ form.body_html }} + {% if form.body_html.help_text %} + {{ form.body_html.help_text }} + {% endif %} + {{ form.body_html.errors }} +
    + +
    + + + {{ form.recipients }} + Hold Ctrl/Cmd to select multiple recipients. + {{ form.recipients.errors }} +
    + + {% if form.non_field_errors %} +
      + {% for error in form.non_field_errors %} +
    • {{ error }}
    • + {% endfor %} +
    + {% endif %} + +
    + +
    +
    +
    + + + + diff --git a/src/mail_tools/templates/mail_tools/list.html b/src/mail_tools/templates/mail_tools/list.html new file mode 100644 index 0000000..4025e86 --- /dev/null +++ b/src/mail_tools/templates/mail_tools/list.html @@ -0,0 +1,39 @@ + + + + + Mail templates + + + +
    +

    Mail template previews

    +

    Select a template to render it with its sample context. Use the broadcast page to send informational emails.

    +

    Go to broadcast composer

    +
      + {% for preview in previews %} +
    • +
      +
      {{ preview.label }}
      +
      {{ preview.slug }}
      +
      +
      + Preview +
      +
    • + {% empty %} +
    • No templates registered.
    • + {% endfor %} +
    +
    + + diff --git a/src/mail_tools/urls.py b/src/mail_tools/urls.py new file mode 100644 index 0000000..11bb3a7 --- /dev/null +++ b/src/mail_tools/urls.py @@ -0,0 +1,11 @@ +from django.urls import path + +from . import views + +app_name = "mail_tools" + +urlpatterns = [ + path("", views.preview_list, name="list"), + path("preview//", views.preview_detail, name="detail"), + path("broadcast/", views.broadcast_email, name="broadcast"), +] diff --git a/src/mail_tools/views.py b/src/mail_tools/views.py new file mode 100644 index 0000000..7062420 --- /dev/null +++ b/src/mail_tools/views.py @@ -0,0 +1,60 @@ +from django.contrib import messages +from django.contrib.admin.views.decorators import staff_member_required +from django.http import Http404, HttpRequest, HttpResponse +from django.shortcuts import redirect, render +from django.template.response import TemplateResponse +from django.urls import reverse + +from accounts.models import Account +from commons.mail_wrapper import send_email_with_template + +from .forms import BroadcastEmailForm +from .registry import all_previews, get_preview + + +@staff_member_required +def preview_list(request: HttpRequest) -> HttpResponse: + return render(request, "mail_tools/list.html", {"previews": list(all_previews())}) + + +@staff_member_required +def preview_detail(request: HttpRequest, slug: str) -> HttpResponse: + try: + preview = get_preview(slug) + except LookupError as exc: + raise Http404(str(exc)) + + context = {"__preview": preview, **preview.context} + return TemplateResponse(request, preview.template_name, context) + + +@staff_member_required +def broadcast_email(request: HttpRequest) -> HttpResponse: + form = BroadcastEmailForm(request.POST or None) + + if request.method == "POST" and form.is_valid(): + preview = form.get_preview() + body_override = form.cleaned_data.get("body_html") + subject = form.cleaned_data["subject"] + recipients = form.cleaned_data["recipients"] + + _send_broadcast(preview, subject, body_override, recipients) + + messages.success(request, f"Queued email using '{preview.label}' for {recipients.count()} recipient(s).") + return redirect(reverse("mail_tools:broadcast")) + + return render( + request, + "mail_tools/broadcast.html", + { + "form": form, + }, + ) + + +def _send_broadcast(preview, subject: str, body_override: str | None, recipients) -> None: + for account in recipients: + if not isinstance(account, Account): + continue + context = preview.build_context(user_name=account.username, overrides={"body_html": body_override}) + send_email_with_template(account.email, subject, preview.template_name, context) diff --git a/src/templates/base_email.html b/src/templates/base_email.html new file mode 100644 index 0000000..19c3819 --- /dev/null +++ b/src/templates/base_email.html @@ -0,0 +1,45 @@ + + + + + + + {% block title %}Unitystation Notification{% endblock %} + + + + + + +
    + + + + + + + +
    + {% block badge_text %}Notification{% endblock %} +

    {% block header_title %}Unitystation Update{% endblock %}

    +

    {% block header_subtitle %}{% endblock %}

    +
    +
    + {% block body_content %}{% endblock %} + {% block supplemental %}{% endblock %} +

    {% block signature %}The Unitystation Team{% endblock %}

    +
    +
    + + + + +
    + {% block footer_note %} + This is an automated email; please do not reply. Contact us at + info@unitystation.org if you need assistance. + {% endblock %} +
    +
    + + diff --git a/src/templates/confirm_template.html b/src/templates/confirm_template.html index 24d129f..405a0a3 100644 --- a/src/templates/confirm_template.html +++ b/src/templates/confirm_template.html @@ -1,48 +1,30 @@ - - - - Welcome to Unitystation - Account Confirmation - - - -
    -

    Welcome to Unitystation, {{ user_name }}!

    -

    Hello {{ user_name }},

    -

    We're excited to have you on board! To get started with your new account, please confirm your email address by clicking the button below:

    -

    Confirm my account

    -

    If you did not initiate this request, please disregard this email, or contact us for support if you feel this is an error.

    -

    Need help? Join our community on Discord for support and interaction.

    -

    Thank you for joining us,

    -

    The Unitystation Team

    -
    - - - +{% extends "base_email.html" %} + +{% block title %}Welcome to Unitystation - Account Confirmation{% endblock %} + +{% block badge_text %}Action Required{% endblock %} +{% block header_title %}Welcome aboard, {{ user_name }}.{% endblock %} +{% block header_subtitle %}Please confirm your Unitystation account to finish the signup.{% endblock %} + +{% block body_content %} +

    Hello {{ user_name }},

    +

    We're thrilled you're joining the crew. Click below to verify your email and access your account.

    +

    + Confirm my account +

    +{% endblock %} + +{% block supplemental %} +
      +
    • + If the button does not work, copy and paste this link into your browser: + {{ link }} +
    • +
    • Didn't request this? Simply ignore this message.
    • +
    • Need help? Join the community on Discord.
    • +
    +{% endblock %} + +{% block signature %} +Thank you for joining Unitystation,
    The Unitystation Team +{% endblock %} diff --git a/src/templates/info_template.html b/src/templates/info_template.html new file mode 100644 index 0000000..a723e42 --- /dev/null +++ b/src/templates/info_template.html @@ -0,0 +1,28 @@ +{% extends "base_email.html" %} + +{% block title %}Unitystation - Information{% endblock %} + +{% block header_cell_style %}padding:28px 32px 16px;background:linear-gradient(135deg,#f0f9ff 0%,#dbeafe 100%);border-bottom:1px solid #dbeafe;{% endblock %} +{% block badge_style %}display:inline-block;padding:4px 12px;font-size:12px;letter-spacing:0.08em;text-transform:uppercase;font-weight:600;color:#0f172a;background-color:#bfdbfe;border-radius:999px;{% endblock %} +{% block subtitle_style %}margin:0;color:#1f2937;font-size:15px;{% endblock %} +{% block body_text_style %}font-size:15px;color:#0f172a;{% endblock %} +{% block badge_text %}Information{% endblock %} +{% block header_title %}Hello {{ user_name }},{% endblock %} + +{% block body_content %} +
    + {{ body_html|safe }} +
    +{% endblock %} + +{% block supplemental %} +

    + This email is for your information only, no action is required. + If you have questions, visit our + Discord. +

    +{% endblock %} + +{% block signature %} +Warm regards,
    The Unitystation Team +{% endblock %} diff --git a/src/templates/password_reset.html b/src/templates/password_reset.html index 9a99bb8..40a04f7 100644 --- a/src/templates/password_reset.html +++ b/src/templates/password_reset.html @@ -1,46 +1,31 @@ - - - - Unitystation - Password Reset - - - -
    -

    Password Reset Request

    -

    Reset password here

    -

    If you did not initiate this request, please disregard this email, or contact us for support if you feel this is an error.

    -

    Need help? Join our community on Discord for support and interaction.

    -

    Thank you for playing unitystation,

    -

    The Unitystation Team

    -
    - - - +{% extends "base_email.html" %} + +{% block title %}Unitystation - Password Reset{% endblock %} + +{% block badge_text %}Action Required{% endblock %} +{% block header_title %}Password reset requested{% endblock %} +{% block header_subtitle %}Use the secure link below to set a new password.{% endblock %} + +{% block body_content %} +

    Hi there,

    +

    We received a request to reset the password for your Unitystation account. If this was you, click the button below to continue.

    +

    + Reset password +

    +{% endblock %} + +{% block supplemental %} +
      +
    • + If the button does not work, copy and paste this link into your browser: + {{ link }} +
    • +
    • The link expires shortly for your security, so please act soon.
    • +
    • If you didn’t make this request, you can safely ignore this email.
    • +
    • Need help? Join the community on Discord.
    • +
    +{% endblock %} + +{% block signature %} +Stay safe,
    The Unitystation Team +{% endblock %} From 3dcaecbe52b9c67e3ccaf7ae97fb6d0ae9d6de1c Mon Sep 17 00:00:00 2001 From: Gilles <43683714+corp-0@users.noreply.github.com> Date: Fri, 14 Nov 2025 01:30:25 -0300 Subject: [PATCH 12/13] fix: add support for trusted CSRF origins so admin forms work again (#108) --- example.env | 3 +++ src/central_command/settings.py | 3 +++ 2 files changed, 6 insertions(+) diff --git a/example.env b/example.env index c30f9a8..be447e5 100644 --- a/example.env +++ b/example.env @@ -18,3 +18,6 @@ DB_PORT=5432 WEBSITE_URL = http://localhost:8000 PASS_RESET_URL_SUFFIX = reset-password/ ACCOUNT_CONFIRMATION_URL_SUFFIX = confirm-email/ + +# trusted origins +CSRF_TRUSTED_ORIGINS=http://localhost:8000,https://prod-api.unitystation.org,https://dev-api.unitystation.org \ No newline at end of file diff --git a/src/central_command/settings.py b/src/central_command/settings.py index 69cf6c4..48df438 100644 --- a/src/central_command/settings.py +++ b/src/central_command/settings.py @@ -38,6 +38,9 @@ ALLOWED_HOSTS = ["*"] if DEBUG else ["localhost", "127.0.0.1"] +_csrf_origins = os.environ.get("CSRF_TRUSTED_ORIGINS", "") +CSRF_TRUSTED_ORIGINS = [origin.strip() for origin in _csrf_origins.split(",") if origin.strip()] + INSTALLED_APPS = [ "django.contrib.admin", "django.contrib.auth", From 41cdcf7b7b0ad1398979a629cb7be05593064893 Mon Sep 17 00:00:00 2001 From: Gilles <43683714+corp-0@users.noreply.github.com> Date: Sat, 22 Nov 2025 18:32:10 -0300 Subject: [PATCH 13/13] fix: increase server heartbeat TTL to 20 seconds (#109) --- src/baby_serverlist/models.py | 8 +++----- src/central_command/settings.py | 1 + src/commons/cache.py | 5 ++--- src/tests/baby_serverlist/test_api.py | 16 +++++----------- src/tests/commons/test_cache.py | 9 ++++++--- 5 files changed, 17 insertions(+), 22 deletions(-) diff --git a/src/baby_serverlist/models.py b/src/baby_serverlist/models.py index 7725e00..4c1155c 100644 --- a/src/baby_serverlist/models.py +++ b/src/baby_serverlist/models.py @@ -6,10 +6,10 @@ from django.db import models from accounts.models import Account -from commons.cache import BABY_SERVER_HEARTBEAT_TTL_SECONDS, get_baby_server_heartbeat +from central_command.settings import BABY_SERVER_STATUS_TTL_SECONDS +from commons.cache import get_baby_server_heartbeat SERVERLIST_TOKEN_SALT = "baby_serverlist.serverlist_token" -LIVE_HEARTBEAT_GRACE_SECONDS = 2 class BabyServer(models.Model): @@ -52,6 +52,4 @@ def is_live(self) -> bool: if heartbeat_time.tzinfo is None: heartbeat_time = heartbeat_time.replace(tzinfo=UTC) - # live if last heartbeat within the cache TTL plus a small grace buffer - ttl_with_grace = BABY_SERVER_HEARTBEAT_TTL_SECONDS + LIVE_HEARTBEAT_GRACE_SECONDS - return datetime.now(tz=UTC) - heartbeat_time <= timedelta(seconds=ttl_with_grace) + return datetime.now(tz=UTC) - heartbeat_time <= timedelta(seconds=BABY_SERVER_STATUS_TTL_SECONDS) diff --git a/src/central_command/settings.py b/src/central_command/settings.py index 48df438..903e900 100644 --- a/src/central_command/settings.py +++ b/src/central_command/settings.py @@ -40,6 +40,7 @@ _csrf_origins = os.environ.get("CSRF_TRUSTED_ORIGINS", "") CSRF_TRUSTED_ORIGINS = [origin.strip() for origin in _csrf_origins.split(",") if origin.strip()] +BABY_SERVER_STATUS_TTL_SECONDS = 20 INSTALLED_APPS = [ "django.contrib.admin", diff --git a/src/commons/cache.py b/src/commons/cache.py index 94c380e..67da8ef 100644 --- a/src/commons/cache.py +++ b/src/commons/cache.py @@ -3,8 +3,7 @@ from django.core.cache import cache -BABY_SERVER_STATUS_TTL_SECONDS = 10 -BABY_SERVER_HEARTBEAT_TTL_SECONDS = 10 +from central_command.settings import BABY_SERVER_STATUS_TTL_SECONDS SERVER_STATUS_KEY_PREFIX = "baby_server_status:" SERVER_HEARTBEAT_KEY_PREFIX = "baby_server_heartbeat:" @@ -42,7 +41,7 @@ def get_many_baby_server_statuses(server_ids: Iterable[str]) -> dict[str, dict[s def set_baby_server_heartbeat(server_id: str, timestamp: str) -> None: """Persist the last-reported timestamp for a server.""" - cache.set(_heartbeat_key(server_id), timestamp, timeout=BABY_SERVER_HEARTBEAT_TTL_SECONDS) + cache.set(_heartbeat_key(server_id), timestamp, timeout=BABY_SERVER_STATUS_TTL_SECONDS) def get_baby_server_heartbeat(server_id: str) -> str | None: diff --git a/src/tests/baby_serverlist/test_api.py b/src/tests/baby_serverlist/test_api.py index d5d01e2..82a98d7 100644 --- a/src/tests/baby_serverlist/test_api.py +++ b/src/tests/baby_serverlist/test_api.py @@ -7,9 +7,9 @@ from rest_framework.test import APITestCase from accounts.models import Account -from baby_serverlist.models import LIVE_HEARTBEAT_GRACE_SECONDS, BabyServer +from baby_serverlist.models import BabyServer +from central_command.settings import BABY_SERVER_STATUS_TTL_SECONDS from commons.cache import ( - BABY_SERVER_HEARTBEAT_TTL_SECONDS, get_baby_server_heartbeat, get_baby_server_status, set_baby_server_heartbeat, @@ -155,9 +155,7 @@ def test_list_owned_baby_servers_live_flag(self) -> None: response = self.client.get(reverse("baby_serverlist:list-owned")) self.assertTrue(response.json()[0]["live"]) - stale_time = datetime.now(tz=UTC) - timedelta( - seconds=BABY_SERVER_HEARTBEAT_TTL_SECONDS + LIVE_HEARTBEAT_GRACE_SECONDS + 1 - ) + stale_time = datetime.now(tz=UTC) - timedelta(seconds=BABY_SERVER_STATUS_TTL_SECONDS + 1) set_baby_server_heartbeat(str(baby_server.id), stale_time.isoformat()) response = self.client.get(reverse("baby_serverlist:list-owned")) @@ -191,15 +189,11 @@ def test_list_baby_servers_ignores_non_whitelisted(self) -> None: def test_baby_server_is_live_respects_heartbeat_ttl(self) -> None: baby_server = BabyServer.objects.create(owner=self.user) - fresh_time = datetime.now(tz=UTC) - timedelta( - seconds=BABY_SERVER_HEARTBEAT_TTL_SECONDS + LIVE_HEARTBEAT_GRACE_SECONDS - 1 - ) + fresh_time = datetime.now(tz=UTC) - timedelta(seconds=BABY_SERVER_STATUS_TTL_SECONDS - 1) set_baby_server_heartbeat(str(baby_server.id), fresh_time.isoformat()) self.assertTrue(baby_server.is_live()) - stale_time = datetime.now(tz=UTC) - timedelta( - seconds=BABY_SERVER_HEARTBEAT_TTL_SECONDS + LIVE_HEARTBEAT_GRACE_SECONDS + 1 - ) + stale_time = datetime.now(tz=UTC) - timedelta(seconds=BABY_SERVER_STATUS_TTL_SECONDS + 1) set_baby_server_heartbeat(str(baby_server.id), stale_time.isoformat()) stored = get_baby_server_heartbeat(str(baby_server.id)) self.assertEqual(stored, stale_time.isoformat()) diff --git a/src/tests/commons/test_cache.py b/src/tests/commons/test_cache.py index 0a8984d..15c57c0 100644 --- a/src/tests/commons/test_cache.py +++ b/src/tests/commons/test_cache.py @@ -2,11 +2,13 @@ from django.test import SimpleTestCase +from central_command.settings import BABY_SERVER_STATUS_TTL_SECONDS from commons import cache as cache_module class CommonsCacheTests(SimpleTestCase): - def test_set_baby_server_status_uses_ephemeral_timeout(self) -> None: + @staticmethod + def test_set_baby_server_status_uses_ephemeral_timeout() -> None: payload = {"ServerName": "test"} server_id = "server-123" @@ -19,7 +21,8 @@ def test_set_baby_server_status_uses_ephemeral_timeout(self) -> None: timeout=cache_module.BABY_SERVER_STATUS_TTL_SECONDS, ) - def test_set_baby_server_heartbeat_uses_ephemeral_timeout(self) -> None: + @staticmethod + def test_set_baby_server_heartbeat_uses_ephemeral_timeout() -> None: timestamp = "2024-01-01T00:00:00+00:00" server_id = "server-456" @@ -29,5 +32,5 @@ def test_set_baby_server_heartbeat_uses_ephemeral_timeout(self) -> None: fake_cache.set.assert_called_once_with( f"{cache_module.SERVER_HEARTBEAT_KEY_PREFIX}{server_id}", timestamp, - timeout=cache_module.BABY_SERVER_HEARTBEAT_TTL_SECONDS, + timeout=BABY_SERVER_STATUS_TTL_SECONDS, )