diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 0000000..9340893 --- /dev/null +++ b/.devcontainer/Dockerfile @@ -0,0 +1,15 @@ +FROM mcr.microsoft.com/devcontainers/python:1-3.11-bullseye + +ENV PYTHONUNBUFFERED 1 + +# [Optional] If your requirements rarely change, uncomment this section to add them to the image. +# COPY requirements.txt /tmp/pip-tmp/ +# RUN pip3 --disable-pip-version-check --no-cache-dir install -r /tmp/pip-tmp/requirements.txt \ +# && rm -rf /tmp/pip-tmp + +# [Optional] Uncomment this section to install additional OS packages. +# RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ +# && apt-get -y install --no-install-recommends + + + diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..bd144bc --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,24 @@ +// For format details, see https://aka.ms/devcontainer.json. For config options, see the +// README at: https://github.com/devcontainers/templates/tree/main/src/postgres +{ + "name": "Python 3 & PostgreSQL", + "dockerComposeFile": "docker-compose.yml", + "service": "app", + "workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}", + + // Features to add to the dev container. More info: https://containers.dev/features. + // "features": {}, + + // Use 'forwardPorts' to make a list of ports inside the container available locally. + // This can be used to network with other containers or the host. + "forwardPorts": [5000, 5432, 8000], + + // Use 'postCreateCommand' to run commands after the container is created. + "postCreateCommand": "pip install --user -r requirements.txt" + + // Configure tool-specific properties. + // "customizations": {}, + + // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. + // "remoteUser": "root" +} diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml new file mode 100644 index 0000000..1c52c98 --- /dev/null +++ b/.devcontainer/docker-compose.yml @@ -0,0 +1,36 @@ +version: '3.8' + +services: + app: + build: + context: .. + dockerfile: .devcontainer/Dockerfile + + volumes: + - ../..:/workspaces:cached + + # Overrides default command so things don't shut down after the process ends. + command: sleep infinity + + # Runs app on the same network as the database container, allows "forwardPorts" in devcontainer.json function. + network_mode: service:db + + # Use "forwardPorts" in **devcontainer.json** to forward an app port locally. + # (Adding the "ports" property to this file will not forward from a Codespace.) + + db: + image: postgres:latest + restart: unless-stopped + volumes: + - postgres-data:/var/lib/postgresql/data + environment: + POSTGRES_USER: postgres + POSTGRES_DB: postgres + POSTGRES_PASSWORD: postgres + command: ["postgres", "-c", "wal_level=logical"] + + # Add "forwardPorts": ["5432"] to **devcontainer.json** to forward PostgreSQL locally. + # (Adding the "ports" property to this file will not forward from a Codespace.) + +volumes: + postgres-data: diff --git a/.env.template b/.env.template new file mode 100644 index 0000000..b5a108e --- /dev/null +++ b/.env.template @@ -0,0 +1,11 @@ +# A default keypair will be used if this is not provided. +POWERSYNC_PRIVATE_KEY= +POWERSYNC_PUBLIC_KEY= + +POWERSYNC_URL=http://localhost:8080 +DATABASE_NAME=postgres +DATABASE_USER=postgres +DATABASE_PASSWORD=mypassword +DATABASE_HOST=localhost +DJANGO_PORT=8000 +DATABASE_PORT=5432 \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..6864f19 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,22 @@ +# Use the official Python image from the Docker Hub +FROM python:3.9 + +# Set environment variables +ENV PYTHONDONTWRITEBYTECODE 1 +ENV PYTHONUNBUFFERED 1 + +# Set work directory +WORKDIR /app + +# Install dependencies +COPY requirements.txt /app/ +RUN pip install --upgrade pip +RUN pip install -r requirements.txt + +# Copy project +COPY . /app/ + +EXPOSE 8000 + +# Run migrations on each boot +CMD ["/bin/sh", "-c", " python manage.py migrate && python manage.py runserver"] \ No newline at end of file diff --git a/README.md b/README.md index 4e00e2a..709399d 100644 --- a/README.md +++ b/README.md @@ -44,11 +44,6 @@ python manage.py migrate Note that one of the migrations creates a test user in the `auth_user` table - you can use it to log into your frontend app. Take note of the user's id and update the hard coded id in the `upload_data` endpoint of `api/views.py` to match this user's id. In production you'd typically want to authenticate the user on this endpoint (using whatever auth mechanism you already have in place) before signing a JWT for use with PowerSync. See an example [here](https://github.com/powersync-ja/powersync-jwks-example/blob/151adf17611bef8a60d9e6cc490827adc4612da9/supabase/functions/powersync-auth/index.ts#L22) -6. Run the following SQL statement on your Postgres database: - -```sql -create publication powersync for table lists, todos; -``` ## Start App diff --git a/api/app_utils.py b/api/app_utils.py index 6cdddf3..fe5eac6 100644 --- a/api/app_utils.py +++ b/api/app_utils.py @@ -1,17 +1,105 @@ import base64 import json import time +import os from decouple import config from jose.constants import ALGORITHMS from jose.exceptions import JWKError from jose.jwt import encode +from jose import jwk +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.backends import default_backend +import random +import string # Function to decode base64url-encoded data + + def base64url_decode(data): padding = b'=' * (4 - (len(data) % 4)) data = data.replace(b'-', b'+').replace(b'_', b'/') + padding return base64.b64decode(data) +# Function to generate a new RSA key pair and return in JWK format + + +def generate_key_pair(): + alg = 'RS256' + kid = 'powersync-' + \ + ''.join(random.choices(string.ascii_lowercase + string.digits, k=10)) + + private_key = rsa.generate_private_key( + public_exponent=65537, + key_size=2048, + backend=default_backend() + ) + + private_key_pem = private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption() + ) + + public_key = private_key.public_key() + public_key_pem = public_key.public_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PublicFormat.SubjectPublicKeyInfo + ) + + private_key_jwk = jwk.construct( + private_key_pem, algorithm=ALGORITHMS.RS256).to_dict() + private_key_jwk.update({'alg': alg, 'kid': kid}) + + public_key_jwk = jwk.construct( + public_key_pem, algorithm=ALGORITHMS.RS256).to_dict() + public_key_jwk.update({'alg': alg, 'kid': kid}) + + private_base64 = base64.urlsafe_b64encode(json.dumps( + private_key_jwk).encode('utf-8')).decode('utf-8') + public_base64 = base64.urlsafe_b64encode(json.dumps( + public_key_jwk).encode('utf-8')).decode('utf-8') + + return private_base64, public_base64 +# Function to ensure keys are available + +# Function to ensure keys are available + + +def ensure_keys(): + global power_sync_private_key_json, power_sync_public_key_json + + power_sync_private_key_b64 = config('POWERSYNC_PRIVATE_KEY', default=None) + power_sync_public_key_b64 = config('POWERSYNC_PUBLIC_KEY', default=None) + + if not power_sync_private_key_b64 or not power_sync_public_key_b64 or not power_sync_private_key_b64.strip() or not power_sync_public_key_b64.strip(): + print('Private key has not been supplied in the environment. A temporary key pair will be generated.') + private_key_base64, public_key_base64 = generate_key_pair() + power_sync_private_key_b64 = private_key_base64 + power_sync_public_key_b64 = public_key_base64 + + # Save the keys to environment variables (only for this session, won't persist across restarts) + os.environ['POWERSYNC_PRIVATE_KEY'] = power_sync_private_key_b64 + os.environ['POWERSYNC_PUBLIC_KEY'] = power_sync_public_key_b64 + + power_sync_private_key_bytes = base64url_decode( + power_sync_private_key_b64.encode('utf-8')) + power_sync_private_key_json = json.loads( + power_sync_private_key_bytes.decode('utf-8')) + + power_sync_public_key_bytes = base64url_decode( + power_sync_public_key_b64.encode('utf-8')) + power_sync_public_key_json = json.loads( + power_sync_public_key_bytes.decode('utf-8')) + + +# Ensure keys are available +ensure_keys() + +# PowerSync URL +power_sync_url = config('POWERSYNC_URL') + + def create_jwt_token(user_id): try: jwt_header = { @@ -38,17 +126,3 @@ def create_jwt_token(user_id): except (JWKError, ValueError, KeyError) as e: raise Exception(f"Error creating JWT token: {str(e)}") - -# PowerSync private key -power_sync_private_key_b64 = config('POWERSYNC_PRIVATE_KEY') -power_sync_public_key_b64 = config('POWERSYNC_PUBLIC_KEY') - -# PowerSync Url -power_sync_url = config('POWERSYNC_URL') - -# PowerSync public key -power_sync_private_key_bytes = base64url_decode(power_sync_private_key_b64.encode('utf-8')) -power_sync_private_key_json = json.loads(power_sync_private_key_bytes.decode('utf-8')) - -power_sync_public_key_bytes = base64url_decode(power_sync_public_key_b64.encode('utf-8')) -power_sync_public_key_json = json.loads(power_sync_public_key_bytes.decode('utf-8')) \ No newline at end of file diff --git a/api/migrations/0002_create_test_user.py b/api/migrations/0002_create_test_user.py index 9b3b7a3..803ae75 100644 --- a/api/migrations/0002_create_test_user.py +++ b/api/migrations/0002_create_test_user.py @@ -1,19 +1,23 @@ +from django.utils import timezone from django.db import migrations from django.contrib.auth import get_user_model + def create_test_user(apps, schema_editor): User = get_user_model() User.objects.create_user( username='testuser', - password='testpassword' + password='testpassword', + last_login=timezone.now() ) + class Migration(migrations.Migration): dependencies = [ - ('api', '0001_initial'), + ('api', '0001_initial'), ] operations = [ migrations.RunPython(create_test_user), - ] \ No newline at end of file + ] diff --git a/api/migrations/0003_create_publication.py b/api/migrations/0003_create_publication.py new file mode 100644 index 0000000..21159ed --- /dev/null +++ b/api/migrations/0003_create_publication.py @@ -0,0 +1,26 @@ +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0002_create_test_user'), + ] + + operations = [ + migrations.RunSQL( + """ + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_publication WHERE pubname = 'powersync' + ) THEN + CREATE PUBLICATION powersync FOR TABLE lists, todos; + END IF; + END $$; + """, + reverse_sql=""" + DROP PUBLICATION IF EXISTS powersync; + """ + ), + ] diff --git a/api/urls.py b/api/urls.py index 0ab5563..b0cfdd2 100644 --- a/api/urls.py +++ b/api/urls.py @@ -2,9 +2,11 @@ from . import views urlpatterns = [ - path('get_powersync_token/', views.get_powersync_token, name='get_powersync_token'), + path('get_powersync_token/', views.get_powersync_token, + name='get_powersync_token'), path('get_keys/', views.get_keys, name='get_keys'), path('get_session/', views.get_session, name='get_session'), path('auth/', views.auth, name='auth'), + path('register/', views.register, name='register'), path('upload_data/', views.upload_data, name='upload_data'), -] \ No newline at end of file +] diff --git a/api/views.py b/api/views.py index 20c717c..284d009 100644 --- a/api/views.py +++ b/api/views.py @@ -1,18 +1,22 @@ +from django.utils import timezone import json +from venv import logger from django.http import HttpResponse, JsonResponse -from django.contrib.auth.models import User from django.contrib.auth import authenticate +from django.contrib.auth import login from django.views.decorators.csrf import csrf_exempt from rest_framework.decorators import api_view from rest_framework.response import Response import api.app_utils as app_utils from .models import Todos, Lists +from django.contrib.auth import get_user_model + @api_view(['GET']) def get_powersync_token(request): try: - # For demo purposes the userId is hardcoded, - # In your app you'll fetch the user from the database + # For demo purposes the userId is hardcoded, + # In your app you'll fetch the user from the database user_id = "4" token = app_utils.create_jwt_token(user_id) return JsonResponse({ @@ -21,6 +25,8 @@ def get_powersync_token(request): }, status=200) except Exception as e: return JsonResponse({"error": str(e)}, status=500) + + @api_view(['GET']) def get_keys(request): try: @@ -32,11 +38,12 @@ def get_keys(request): except Exception as e: return JsonResponse({"error": str(e)}, status=500) + @api_view(['GET']) def get_session(request): try: # For demo purposes the session is always valid, - # In your app you'll need to handle user sessions + # In your app you'll need to handle user sessions # and invalidate the session after expiry. return JsonResponse({ "session": "valid" @@ -44,6 +51,7 @@ def get_session(request): except Exception as e: return JsonResponse({"error": str(e)}, status=500) + @csrf_exempt def auth(request): # For demo purposes the username and password are in plain text, @@ -51,7 +59,7 @@ def auth(request): data = json.loads(request.body.decode('utf-8')) username = data.get('username') password = data.get('password') - try: + try: user = authenticate(username=username, password=password) if user is not None: token = app_utils.create_jwt_token(user.id) @@ -68,6 +76,30 @@ def auth(request): logger.error(f"Unexpected error: {e}") return JsonResponse({'message': 'Internal server error'}, status=500) + +@csrf_exempt +def register(request): + # For demo purposes the username and password are in plain text, + # In your app you must handle usernames and passwords properly. + data = json.loads(request.body.decode('utf-8')) + username = data.get('username') + password = data.get('password') + try: + User = get_user_model() + if not User.objects.filter(username=username).exists(): + User.objects.create_user( + username=username, + password=password, + last_login=timezone.now() + ) + return JsonResponse({}, status=200) + else: + return JsonResponse({'message': 'Username is taken'}, status=401) + except Exception as e: + logger.error(f"Unexpected error: {e}") + return JsonResponse({'message': 'Internal server error'}, status=500) + + @api_view(['PUT', 'PATCH', 'DELETE']) def upload_data(request): op = json.loads(request.body.decode('utf-8')) @@ -92,7 +124,7 @@ def upload_data(request): upsertList(data) return Response({'message': 'List created'}, status=200) elif request.method == 'PATCH': - updateList(data) + updateList(data) return HttpResponse({'message': 'List updated'}, status=200) elif request.method == 'DELETE': try: @@ -101,7 +133,8 @@ def upload_data(request): return HttpResponse({'message': 'List deleted'}, status=200) except Lists.DoesNotExist: return HttpResponse({'message': 'List does not exist'}, status=404) - + + def upsertTodo(data): try: todo = Todos.objects.get(id=data.get('id')) @@ -110,9 +143,11 @@ def upsertTodo(data): todo.list_id = data.get('list_id') todo.save() except Todos.DoesNotExist: - todo = Todos(id=data.get('id'), description=data.get('description'), created_by=data.get('created_by'), list_id=data.get('list_id')) + todo = Todos(id=data.get('id'), description=data.get( + 'description'), created_by=data.get('created_by'), list_id=data.get('list_id')) todo.save() + def updateTodo(data): todo = Todos.objects.get(id=data.get('id')) if todo is not None: @@ -130,6 +165,7 @@ def updateTodo(data): todo.completed_at = data.get('completed_at') todo.save() + def upsertList(data): try: list = Lists.objects.get(id=data.get('id')) @@ -139,13 +175,15 @@ def upsertList(data): list.save() return Response({'message': 'List updated'}, status=200) except Lists.DoesNotExist: - list = Lists(id=data.get('id'), created_at=data.get('created_at'), name=data.get('name'), owner_id=data.get('owner_id')) + list = Lists(id=data.get('id'), created_at=data.get( + 'created_at'), name=data.get('name'), owner_id=data.get('owner_id')) list.save() + def updateList(data): list = Lists.objects.get(id=data.get('id')) if list is not None: list.created_at = data.get('created_at') list.name = data.get('name') list.owner_id = data.get('owner_id') - list.save() \ No newline at end of file + list.save() diff --git a/manage.py b/manage.py index 07e8b4c..9f1dcce 100755 --- a/manage.py +++ b/manage.py @@ -3,10 +3,22 @@ import os import sys +from django.core.management.commands.runserver import Command as runserver + +# Default port (e.g., 8000) +DEFAULT_PORT = 8000 + +# Read PORT from environment variable, fallback to DEFAULT_PORT +PORT = int(os.getenv('DJANGO_PORT', DEFAULT_PORT)) + +runserver.default_port = PORT +runserver.default_addr = '0.0.0.0' + def main(): """Run administrative tasks.""" - os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'todo_list_custom_backend.settings') + os.environ.setdefault('DJANGO_SETTINGS_MODULE', + 'todo_list_custom_backend.settings') try: from django.core.management import execute_from_command_line except ImportError as exc: diff --git a/requirements.txt b/requirements.txt index 90127c1..0f81462 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,3 +3,4 @@ djangorestframework==3.14.0 python-decouple==3.8 python_jose==3.3.0 psycopg2==2.9.9 +cryptography==42.0.8 diff --git a/todo_list_custom_backend/settings.py b/todo_list_custom_backend/settings.py index 6ba49ed..d724840 100644 --- a/todo_list_custom_backend/settings.py +++ b/todo_list_custom_backend/settings.py @@ -29,8 +29,7 @@ APPEND_SLASH = False ALLOWED_HOSTS = [ - 'd9ae-2601-282-1800-d970-7da1-854e-b9a7-98d.ngrok-free.app', - '127.0.0.1' + '*' ]