From bddf53c29ffb15e42fa0d425a487ddaa7a1c088c Mon Sep 17 00:00:00 2001 From: Steven Ontong Date: Mon, 1 Jul 2024 11:37:04 +0000 Subject: [PATCH 1/8] add ability to use default key --- .env.template | 8 ++ api/app_utils.py | 102 ++++++++++++++++++++---- api/migrations/0002_create_test_user.py | 10 ++- api/urls.py | 4 +- requirements.txt | 1 + todo_list_custom_backend/settings.py | 3 +- 6 files changed, 108 insertions(+), 20 deletions(-) create mode 100644 .env.template diff --git a/.env.template b/.env.template new file mode 100644 index 0000000..86fbfd8 --- /dev/null +++ b/.env.template @@ -0,0 +1,8 @@ +POWERSYNC_PRIVATE_KEY= +POWERSYNC_PUBLIC_KEY= +POWERSYNC_URL=http://localhost:8080 +DATABASE_NAME=django_example +DATABASE_USER=postgres +DATABASE_PASSWORD=mypassword +DATABASE_HOST=localhost +DATABASE_PORT=5432 \ No newline at end of file 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/urls.py b/api/urls.py index 0ab5563..ba74f93 100644 --- a/api/urls.py +++ b/api/urls.py @@ -2,9 +2,9 @@ from . import views urlpatterns = [ - path('get_powersync_token/', views.get_powersync_token, name='get_powersync_token'), + path('get_token/', views.get_token, name='get_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('upload_data/', views.upload_data, name='upload_data'), -] \ No newline at end of file +] 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..77a9a90 100644 --- a/todo_list_custom_backend/settings.py +++ b/todo_list_custom_backend/settings.py @@ -30,7 +30,8 @@ ALLOWED_HOSTS = [ 'd9ae-2601-282-1800-d970-7da1-854e-b9a7-98d.ngrok-free.app', - '127.0.0.1' + '127.0.0.1', + 'localhost' ] From a13ed697952660f59a935449e14fe4676772a0c5 Mon Sep 17 00:00:00 2001 From: Steven Ontong Date: Mon, 1 Jul 2024 16:27:01 +0000 Subject: [PATCH 2/8] added user registration and Dockerfiles --- .devcontainer/Dockerfile | 15 ++++++++++++ .devcontainer/devcontainer.json | 24 +++++++++++++++++++ .devcontainer/docker-compose.yml | 36 ++++++++++++++++++++++++++++ Dockerfile | 20 ++++++++++++++++ api/urls.py | 1 + api/views.py | 27 ++++++++++++++++++++- todo_list_custom_backend/settings.py | 3 ++- 7 files changed, 124 insertions(+), 2 deletions(-) create mode 100644 .devcontainer/Dockerfile create mode 100644 .devcontainer/devcontainer.json create mode 100644 .devcontainer/docker-compose.yml create mode 100644 Dockerfile 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/Dockerfile b/Dockerfile new file mode 100644 index 0000000..207614a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,20 @@ +# Use the official Python image from the Docker Hub +FROM python:3.9-slim + +# 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/ + +# Run the entrypoint script +CMD ["/bin/sh", "-c", " python manage.py migrate && python manage.py runserver"] \ No newline at end of file diff --git a/api/urls.py b/api/urls.py index ba74f93..1d52f50 100644 --- a/api/urls.py +++ b/api/urls.py @@ -6,5 +6,6 @@ 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'), ] diff --git a/api/views.py b/api/views.py index 29fa588..761b2a1 100644 --- a/api/views.py +++ b/api/views.py @@ -1,12 +1,15 @@ +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_token(request): @@ -67,6 +70,28 @@ def auth(request): except Exception as e: 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): diff --git a/todo_list_custom_backend/settings.py b/todo_list_custom_backend/settings.py index 77a9a90..fc671dd 100644 --- a/todo_list_custom_backend/settings.py +++ b/todo_list_custom_backend/settings.py @@ -31,7 +31,8 @@ ALLOWED_HOSTS = [ 'd9ae-2601-282-1800-d970-7da1-854e-b9a7-98d.ngrok-free.app', '127.0.0.1', - 'localhost' + 'localhost', + 'host.docker.internal' ] From 9e5a9ccafe995c6e2eecd09227b8f4442c3a5e8d Mon Sep 17 00:00:00 2001 From: Steven Ontong Date: Tue, 2 Jul 2024 11:50:59 +0000 Subject: [PATCH 3/8] allow port to be overriden --- .env.template | 1 + Dockerfile | 4 +++- manage.py | 10 ++++++++++ todo_list_custom_backend/settings.py | 1 - 4 files changed, 14 insertions(+), 2 deletions(-) diff --git a/.env.template b/.env.template index 86fbfd8..d200962 100644 --- a/.env.template +++ b/.env.template @@ -5,4 +5,5 @@ DATABASE_NAME=django_example 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 index 207614a..43478f0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # Use the official Python image from the Docker Hub -FROM python:3.9-slim +FROM python:3.9 # Set environment variables ENV PYTHONDONTWRITEBYTECODE 1 @@ -16,5 +16,7 @@ RUN pip install -r requirements.txt # Copy project COPY . /app/ +EXPOSE 8000 + # Run the entrypoint script CMD ["/bin/sh", "-c", " python manage.py migrate && python manage.py runserver"] \ No newline at end of file diff --git a/manage.py b/manage.py index 07e8b4c..3f769e6 100755 --- a/manage.py +++ b/manage.py @@ -3,6 +3,16 @@ 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 + def main(): """Run administrative tasks.""" diff --git a/todo_list_custom_backend/settings.py b/todo_list_custom_backend/settings.py index fc671dd..204d78c 100644 --- a/todo_list_custom_backend/settings.py +++ b/todo_list_custom_backend/settings.py @@ -29,7 +29,6 @@ APPEND_SLASH = False ALLOWED_HOSTS = [ - 'd9ae-2601-282-1800-d970-7da1-854e-b9a7-98d.ngrok-free.app', '127.0.0.1', 'localhost', 'host.docker.internal' From e72ef816670f423b140b37a0310ae132812bc24f Mon Sep 17 00:00:00 2001 From: Steven Ontong Date: Tue, 2 Jul 2024 13:16:55 +0000 Subject: [PATCH 4/8] create publication by migration --- README.md | 5 ----- api/migrations/0003_create_publication.py | 27 +++++++++++++++++++++++ 2 files changed, 27 insertions(+), 5 deletions(-) create mode 100644 api/migrations/0003_create_publication.py diff --git a/README.md b/README.md index 388086c..73a4c94 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/migrations/0003_create_publication.py b/api/migrations/0003_create_publication.py new file mode 100644 index 0000000..e012916 --- /dev/null +++ b/api/migrations/0003_create_publication.py @@ -0,0 +1,27 @@ +# yourappname/migrations/000X_your_migration_name.py + +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; + """ + ), + ] \ No newline at end of file From e00af2ccf21c6a3c991db1180ba49447936d1c1d Mon Sep 17 00:00:00 2001 From: Steven Ontong Date: Tue, 2 Jul 2024 13:45:24 +0000 Subject: [PATCH 5/8] allow outside connections --- manage.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/manage.py b/manage.py index 3f769e6..9f1dcce 100755 --- a/manage.py +++ b/manage.py @@ -12,11 +12,13 @@ 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: From eccd2544f479eb0f0e6703bd0e7ce782705e9a55 Mon Sep 17 00:00:00 2001 From: Steven Ontong Date: Tue, 2 Jul 2024 14:34:48 +0000 Subject: [PATCH 6/8] allow all hosts --- todo_list_custom_backend/settings.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/todo_list_custom_backend/settings.py b/todo_list_custom_backend/settings.py index 204d78c..d724840 100644 --- a/todo_list_custom_backend/settings.py +++ b/todo_list_custom_backend/settings.py @@ -29,9 +29,7 @@ APPEND_SLASH = False ALLOWED_HOSTS = [ - '127.0.0.1', - 'localhost', - 'host.docker.internal' + '*' ] From ce2c217c9225d6d29798d03c335d1995ecbc9086 Mon Sep 17 00:00:00 2001 From: Steven Ontong Date: Tue, 2 Jul 2024 15:30:17 +0000 Subject: [PATCH 7/8] fix endpoint --- api/urls.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/api/urls.py b/api/urls.py index bb82297..b0cfdd2 100644 --- a/api/urls.py +++ b/api/urls.py @@ -2,7 +2,8 @@ from . import views urlpatterns = [ - path('get_token/', views.get_powersync_token, name='get_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'), From adbb0ba1c85be4701340351eec8c09e565233076 Mon Sep 17 00:00:00 2001 From: Steven Ontong Date: Wed, 3 Jul 2024 11:14:43 +0000 Subject: [PATCH 8/8] cleanup --- .env.template | 4 +++- Dockerfile | 2 +- api/migrations/0003_create_publication.py | 7 +++---- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/.env.template b/.env.template index d200962..b5a108e 100644 --- a/.env.template +++ b/.env.template @@ -1,7 +1,9 @@ +# A default keypair will be used if this is not provided. POWERSYNC_PRIVATE_KEY= POWERSYNC_PUBLIC_KEY= + POWERSYNC_URL=http://localhost:8080 -DATABASE_NAME=django_example +DATABASE_NAME=postgres DATABASE_USER=postgres DATABASE_PASSWORD=mypassword DATABASE_HOST=localhost diff --git a/Dockerfile b/Dockerfile index 43478f0..6864f19 100644 --- a/Dockerfile +++ b/Dockerfile @@ -18,5 +18,5 @@ COPY . /app/ EXPOSE 8000 -# Run the entrypoint script +# 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/api/migrations/0003_create_publication.py b/api/migrations/0003_create_publication.py index e012916..21159ed 100644 --- a/api/migrations/0003_create_publication.py +++ b/api/migrations/0003_create_publication.py @@ -1,7 +1,6 @@ -# yourappname/migrations/000X_your_migration_name.py - from django.db import migrations + class Migration(migrations.Migration): dependencies = [ @@ -10,7 +9,7 @@ class Migration(migrations.Migration): operations = [ migrations.RunSQL( - """ + """ DO $$ BEGIN IF NOT EXISTS ( @@ -24,4 +23,4 @@ class Migration(migrations.Migration): DROP PUBLICATION IF EXISTS powersync; """ ), - ] \ No newline at end of file + ]