├── tests ├── r2 │ ├── __init__.py │ └── test_storage.py ├── servers │ └── r2 │ │ ├── __init__.py │ │ ├── src │ │ ├── __init__.py │ │ ├── app │ │ │ ├── __init__.py │ │ │ ├── asgi.py │ │ │ ├── wsgi.py │ │ │ ├── settings.py │ │ │ └── urls.py │ │ ├── index.py │ │ └── manage.py │ │ ├── .gitignore │ │ ├── pyproject.toml │ │ ├── package.json │ │ └── wrangler.jsonc ├── __init__.py ├── d1 │ ├── __init__.py │ ├── test_worker.py │ └── test_admin.py ├── durable_objects │ ├── __init__.py │ ├── test_worker.py │ └── test_admin.py └── utils.py ├── django_cf ├── db │ ├── __init__.py │ ├── backends │ │ ├── __init__.py │ │ ├── d1 │ │ │ ├── __init__.py │ │ │ └── base.py │ │ └── do │ │ │ ├── __init__.py │ │ │ ├── storage.py │ │ │ └── base.py │ └── base_engine.py ├── storage │ ├── __init__.py │ └── r2.py ├── middleware │ ├── __init__.py │ └── CloudflareAccessMiddleware.py └── __init__.py ├── templates ├── d1 │ ├── __init__.py │ ├── src │ │ ├── __init__.py │ │ ├── app │ │ │ ├── __init__.py │ │ │ ├── asgi.py │ │ │ ├── wsgi.py │ │ │ ├── urls.py │ │ │ └── settings.py │ │ ├── blog │ │ │ ├── __init__.py │ │ │ ├── migrations │ │ │ │ ├── __init__.py │ │ │ │ └── 0001_initial.py │ │ │ ├── tests.py │ │ │ ├── admin.py │ │ │ ├── apps.py │ │ │ ├── models.py │ │ │ ├── urls.py │ │ │ ├── views.py │ │ │ └── static │ │ │ │ └── css │ │ │ │ └── style.css │ │ ├── index.py │ │ ├── manage.py │ │ └── templates │ │ │ ├── blog │ │ │ ├── post_detail.html │ │ │ ├── post_list.html │ │ │ └── base.html │ │ │ └── landing.html │ ├── package.json │ ├── pyproject.toml │ ├── wrangler.jsonc │ ├── .gitignore │ └── README.md └── durable-objects │ ├── __init__.py │ ├── src │ ├── __init__.py │ ├── app │ │ ├── __init__.py │ │ ├── asgi.py │ │ ├── wsgi.py │ │ ├── urls.py │ │ └── settings.py │ ├── blog │ │ ├── __init__.py │ │ ├── migrations │ │ │ ├── __init__.py │ │ │ └── 0001_initial.py │ │ ├── tests.py │ │ ├── admin.py │ │ ├── apps.py │ │ ├── models.py │ │ ├── urls.py │ │ ├── views.py │ │ └── static │ │ │ └── css │ │ │ └── style.css │ ├── index.py │ ├── manage.py │ └── templates │ │ ├── blog │ │ ├── post_detail.html │ │ ├── post_list.html │ │ └── base.html │ │ └── landing.html │ ├── package.json │ ├── pyproject.toml │ ├── wrangler.jsonc │ ├── .gitignore │ └── README.md ├── pnpm-workspace.yaml ├── DEVELOPMENT.md ├── .github ├── dependabot.yml └── workflows │ └── ci.yml ├── LICENSE ├── pyproject.toml ├── package.json ├── .gitignore └── AGENTS.md /tests/r2/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /django_cf/db/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /templates/d1/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /templates/d1/src/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/servers/r2/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /django_cf/db/backends/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /templates/d1/src/app/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /templates/d1/src/blog/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/servers/r2/src/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /django_cf/db/backends/d1/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /django_cf/db/backends/do/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /templates/durable-objects/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/servers/r2/src/app/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /templates/durable-objects/src/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /templates/d1/src/blog/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /templates/durable-objects/src/app/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /templates/durable-objects/src/blog/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /templates/durable-objects/src/blog/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - "templates/*" 3 | - "tests/servers/*" 4 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | # This file makes the 'tests' directory a Python package. 2 | -------------------------------------------------------------------------------- /tests/d1/__init__.py: -------------------------------------------------------------------------------- 1 | # This file makes the 'tests' directory a Python package. 2 | -------------------------------------------------------------------------------- /django_cf/storage/__init__.py: -------------------------------------------------------------------------------- 1 | from .r2 import R2Storage 2 | 3 | __all__ = ['R2Storage'] 4 | -------------------------------------------------------------------------------- /tests/durable_objects/__init__.py: -------------------------------------------------------------------------------- 1 | # This file makes the 'tests' directory a Python package. 2 | -------------------------------------------------------------------------------- /django_cf/middleware/__init__.py: -------------------------------------------------------------------------------- 1 | from .CloudflareAccessMiddleware import CloudflareAccessMiddleware 2 | -------------------------------------------------------------------------------- /templates/d1/src/blog/tests.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | # Create your tests here. 4 | -------------------------------------------------------------------------------- /templates/durable-objects/src/blog/tests.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | # Create your tests here. 4 | -------------------------------------------------------------------------------- /templates/d1/src/blog/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from .models import Post 3 | 4 | admin.site.register(Post) 5 | -------------------------------------------------------------------------------- /tests/servers/r2/.gitignore: -------------------------------------------------------------------------------- 1 | .venv 2 | .venv-workers 3 | python_modules 4 | node_modules 5 | .wrangler 6 | staticfiles 7 | src/django_cf 8 | -------------------------------------------------------------------------------- /templates/durable-objects/src/blog/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from .models import Post 3 | 4 | admin.site.register(Post) 5 | -------------------------------------------------------------------------------- /django_cf/db/backends/do/storage.py: -------------------------------------------------------------------------------- 1 | storage = None 2 | 3 | def set_storage(db): 4 | global storage 5 | storage = db 6 | 7 | def get_storage(): 8 | return storage 9 | -------------------------------------------------------------------------------- /DEVELOPMENT.md: -------------------------------------------------------------------------------- 1 | # Releasing a new version 2 | 3 | ```bash 4 | pip install build twine 5 | ``` 6 | 7 | ```bash 8 | python3 -m build 9 | python3 -m twine upload dist/* 10 | ``` 11 | -------------------------------------------------------------------------------- /templates/d1/src/blog/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class BlogConfig(AppConfig): 5 | default_auto_field = 'django.db.models.BigAutoField' 6 | name = 'blog' 7 | -------------------------------------------------------------------------------- /templates/durable-objects/src/blog/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class BlogConfig(AppConfig): 5 | default_auto_field = 'django.db.models.BigAutoField' 6 | name = 'blog' 7 | -------------------------------------------------------------------------------- /templates/d1/src/index.py: -------------------------------------------------------------------------------- 1 | from workers import WorkerEntrypoint 2 | from django_cf import DjangoCF 3 | 4 | class Default(DjangoCF, WorkerEntrypoint): 5 | def get_app(self): 6 | from app.wsgi import application 7 | return application 8 | -------------------------------------------------------------------------------- /tests/servers/r2/src/index.py: -------------------------------------------------------------------------------- 1 | from workers import WorkerEntrypoint 2 | from django_cf import DjangoCF 3 | 4 | class Default(DjangoCF, WorkerEntrypoint): 5 | def get_app(self): 6 | from app.wsgi import application 7 | return application 8 | -------------------------------------------------------------------------------- /templates/d1/src/blog/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.utils import timezone 3 | 4 | class Post(models.Model): 5 | title = models.CharField(max_length=200) 6 | content = models.TextField() 7 | pub_date = models.DateTimeField(default=timezone.now) 8 | 9 | def __str__(self): 10 | return self.title 11 | -------------------------------------------------------------------------------- /templates/durable-objects/src/blog/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.utils import timezone 3 | 4 | class Post(models.Model): 5 | title = models.CharField(max_length=200) 6 | content = models.TextField() 7 | pub_date = models.DateTimeField(default=timezone.now) 8 | 9 | def __str__(self): 10 | return self.title 11 | -------------------------------------------------------------------------------- /templates/d1/src/blog/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | from django.views.generic import TemplateView 3 | 4 | from . import views 5 | 6 | app_name = 'blog' 7 | urlpatterns = [ 8 | path('', TemplateView.as_view(template_name='landing.html'), name='landing'), 9 | path('blog/', views.post_list, name='post_list'), 10 | path('blog/post//', views.post_detail, name='post_detail'), 11 | ] 12 | -------------------------------------------------------------------------------- /tests/servers/r2/pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "django-cf-r2-test-server" 3 | version = "0.1.0" 4 | description = "R2 test server for django-cf integration tests" 5 | requires-python = ">=3.12" 6 | readme = "README.md" 7 | dependencies = [ 8 | "django-cf", 9 | "django==5.2.6", 10 | "tzdata", 11 | ] 12 | 13 | [dependency-groups] 14 | dev = [ 15 | "workers-py", 16 | "workers-runtime-sdk" 17 | ] 18 | -------------------------------------------------------------------------------- /templates/d1/src/blog/views.py: -------------------------------------------------------------------------------- 1 | from django.shortcuts import render, get_object_or_404 2 | from .models import Post 3 | 4 | def post_list(request): 5 | posts = Post.objects.order_by('-pub_date') 6 | return render(request, 'blog/post_list.html', {'posts': posts}) 7 | 8 | def post_detail(request, pk): 9 | post = get_object_or_404(Post, pk=pk) 10 | return render(request, 'blog/post_detail.html', {'post': post}) 11 | -------------------------------------------------------------------------------- /templates/durable-objects/src/blog/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | from django.views.generic import TemplateView 3 | 4 | from . import views 5 | 6 | app_name = 'blog' 7 | urlpatterns = [ 8 | path('', TemplateView.as_view(template_name='landing.html'), name='landing'), 9 | path('blog/', views.post_list, name='post_list'), 10 | path('blog/post//', views.post_detail, name='post_detail'), 11 | ] 12 | -------------------------------------------------------------------------------- /templates/durable-objects/src/blog/views.py: -------------------------------------------------------------------------------- 1 | from django.shortcuts import render, get_object_or_404 2 | from .models import Post 3 | 4 | def post_list(request): 5 | posts = Post.objects.order_by('-pub_date') 6 | return render(request, 'blog/post_list.html', {'posts': posts}) 7 | 8 | def post_detail(request, pk): 9 | post = get_object_or_404(Post, pk=pk) 10 | return render(request, 'blog/post_detail.html', {'post': post}) 11 | -------------------------------------------------------------------------------- /templates/d1/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "django-on-workers", 3 | "version": "0.0.0", 4 | "private": true, 5 | "scripts": { 6 | "dependencies": "uv pip install --system workers-py --upgrade && uv run pywrangler sync", 7 | "deploy": "npm run dependencies && uv run pywrangler deploy", 8 | "dev": "uv run pywrangler dev", 9 | "start": "uv run pywrangler dev" 10 | }, 11 | "devDependencies": { 12 | "wrangler": "^4.51.0" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /tests/servers/r2/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "django-on-workers", 3 | "version": "0.0.0", 4 | "private": true, 5 | "scripts": { 6 | "dependencies": "uv pip install --system workers-py --upgrade && uv run pywrangler sync", 7 | "deploy": "npm run dependencies && uv run pywrangler deploy", 8 | "dev": "uv run pywrangler dev", 9 | "start": "uv run pywrangler dev" 10 | }, 11 | "devDependencies": { 12 | "wrangler": "^4.51.0" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /templates/d1/src/app/asgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | ASGI config for app project. 3 | 4 | It exposes the ASGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/5.1/howto/deployment/asgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.asgi import get_asgi_application 13 | 14 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'app.settings') 15 | 16 | application = get_asgi_application() 17 | -------------------------------------------------------------------------------- /templates/d1/src/app/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for app project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/5.1/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'app.settings') 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /templates/durable-objects/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "django-on-workers", 3 | "version": "0.0.0", 4 | "private": true, 5 | "scripts": { 6 | "dependencies": "uv pip install --system workers-py --upgrade && uv run pywrangler sync", 7 | "deploy": "npm run dependencies && uv run pywrangler deploy", 8 | "dev": "uv run pywrangler dev", 9 | "start": "uv run pywrangler dev" 10 | }, 11 | "devDependencies": { 12 | "wrangler": "^4.51.0" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /tests/servers/r2/src/app/asgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | ASGI config for app project. 3 | 4 | It exposes the ASGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/5.1/howto/deployment/asgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.asgi import get_asgi_application 13 | 14 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'app.settings') 15 | 16 | application = get_asgi_application() 17 | -------------------------------------------------------------------------------- /tests/servers/r2/src/app/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for app project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/5.1/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'app.settings') 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /templates/durable-objects/src/app/asgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | ASGI config for app project. 3 | 4 | It exposes the ASGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/5.1/howto/deployment/asgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.asgi import get_asgi_application 13 | 14 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'app.settings') 15 | 16 | application = get_asgi_application() 17 | -------------------------------------------------------------------------------- /templates/durable-objects/src/app/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for app project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/5.1/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'app.settings') 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /templates/d1/pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "django-on-workers-with-d1" 3 | version = "0.1.0" 4 | description = "This template provides a starting point for running a Django application on Cloudflare Workers, utilizing Cloudflare D1 for serverless SQL database." 5 | requires-python = ">=3.12" 6 | readme = "README.md" 7 | dependencies = [ 8 | "django-cf", 9 | "django==5.2.6", 10 | "tzdata", 11 | ] 12 | 13 | [dependency-groups] 14 | dev = [ 15 | "workers-py", 16 | "workers-runtime-sdk" 17 | ] 18 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "npm" 4 | directories: 5 | - "/pnpm-lock.yaml" 6 | - "/packages/worker" 7 | - "/packages/worker/dev" 8 | - "/packages/github-action" 9 | schedule: 10 | interval: "weekly" 11 | groups: 12 | prod-deps: 13 | dependency-type: "production" 14 | dev-deps: 15 | dependency-type: "development" 16 | - package-ecosystem: "github-actions" 17 | directory: "/" 18 | schedule: 19 | interval: "weekly" 20 | -------------------------------------------------------------------------------- /templates/durable-objects/pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "django-on-workers-with-durable-objects" 3 | version = "0.1.0" 4 | description = "This template provides a starting point for running a Django application on Cloudflare Workers, utilizing Cloudflare Durable Objects for stateful data persistence." 5 | requires-python = ">=3.12" 6 | readme = "README.md" 7 | dependencies = [ 8 | "django-cf", 9 | "django==5.2.6", 10 | "tzdata", 11 | ] 12 | 13 | [dependency-groups] 14 | dev = [ 15 | "workers-py", 16 | "workers-runtime-sdk" 17 | ] 18 | -------------------------------------------------------------------------------- /templates/durable-objects/src/index.py: -------------------------------------------------------------------------------- 1 | from workers import DurableObject, WorkerEntrypoint 2 | 3 | from django_cf import DjangoCFDurableObject 4 | 5 | 6 | class DjangoDO(DjangoCFDurableObject, DurableObject): 7 | def get_app(self): 8 | from app.wsgi import application # Update according to your project structure 9 | return application 10 | 11 | 12 | class Default(WorkerEntrypoint): 13 | async def fetch(self, request): 14 | # Pick a DO name based on the request 15 | id = self.env.DO_STORAGE.idFromName("A") 16 | obj = self.env.DO_STORAGE.get(id) 17 | return await obj.fetch(request) 18 | -------------------------------------------------------------------------------- /templates/d1/src/blog/static/css/style.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: Arial, sans-serif; 3 | max-width: 800px; 4 | margin: 0 auto; 5 | padding: 20px; 6 | } 7 | 8 | header { 9 | text-align: center; 10 | border-bottom: 1px solid #ccc; 11 | margin-bottom: 20px; 12 | } 13 | 14 | nav a { 15 | margin-right: 10px; 16 | text-decoration: none; 17 | color: #007bff; 18 | } 19 | 20 | article { 21 | margin-bottom: 20px; 22 | padding-bottom: 20px; 23 | border-bottom: 1px solid #eee; 24 | } 25 | 26 | h2, h3 { 27 | color: #333; 28 | } 29 | 30 | a { 31 | color: #007bff; 32 | text-decoration: none; 33 | } 34 | 35 | a:hover { 36 | text-decoration: underline; 37 | } 38 | -------------------------------------------------------------------------------- /templates/durable-objects/src/blog/static/css/style.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: Arial, sans-serif; 3 | max-width: 800px; 4 | margin: 0 auto; 5 | padding: 20px; 6 | } 7 | 8 | header { 9 | text-align: center; 10 | border-bottom: 1px solid #ccc; 11 | margin-bottom: 20px; 12 | } 13 | 14 | nav a { 15 | margin-right: 10px; 16 | text-decoration: none; 17 | color: #007bff; 18 | } 19 | 20 | article { 21 | margin-bottom: 20px; 22 | padding-bottom: 20px; 23 | border-bottom: 1px solid #eee; 24 | } 25 | 26 | h2, h3 { 27 | color: #333; 28 | } 29 | 30 | a { 31 | color: #007bff; 32 | text-decoration: none; 33 | } 34 | 35 | a:hover { 36 | text-decoration: underline; 37 | } 38 | -------------------------------------------------------------------------------- /tests/d1/test_worker.py: -------------------------------------------------------------------------------- 1 | import requests 2 | 3 | from ..utils import d1_web_server # NOQA 4 | 5 | def test_migrations(d1_web_server): 6 | """Run migrations.""" 7 | response = requests.get(f"{d1_web_server.base_url}/__run_migrations__/") 8 | assert response.status_code == 200 9 | assert response.json() == {"status": "success", "message": "Migrations applied."} 10 | 11 | def test_create_admin(d1_web_server): 12 | """Create an admin user a second time should return different state.""" 13 | response = requests.get(f"{d1_web_server.base_url}/__create_admin__/") 14 | assert response.status_code == 200 15 | assert response.json() == {"status": "info", "message": f"Admin user 'admin' already exists."} 16 | 17 | -------------------------------------------------------------------------------- /templates/d1/src/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Django's command-line utility for administrative tasks.""" 3 | import os 4 | import sys 5 | 6 | 7 | def main(): 8 | """Run administrative tasks.""" 9 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'app.settings') 10 | try: 11 | from django.core.management import execute_from_command_line 12 | except ImportError as exc: 13 | raise ImportError( 14 | "Couldn't import Django. Are you sure it's installed and " 15 | "available on your PYTHONPATH environment variable? Did you " 16 | "forget to activate a virtual environment?" 17 | ) from exc 18 | execute_from_command_line(sys.argv) 19 | 20 | 21 | if __name__ == '__main__': 22 | main() 23 | -------------------------------------------------------------------------------- /tests/servers/r2/src/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Django's command-line utility for administrative tasks.""" 3 | import os 4 | import sys 5 | 6 | 7 | def main(): 8 | """Run administrative tasks.""" 9 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'app.settings') 10 | try: 11 | from django.core.management import execute_from_command_line 12 | except ImportError as exc: 13 | raise ImportError( 14 | "Couldn't import Django. Are you sure it's installed and " 15 | "available on your PYTHONPATH environment variable? Did you " 16 | "forget to activate a virtual environment?" 17 | ) from exc 18 | execute_from_command_line(sys.argv) 19 | 20 | 21 | if __name__ == '__main__': 22 | main() 23 | -------------------------------------------------------------------------------- /templates/durable-objects/src/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Django's command-line utility for administrative tasks.""" 3 | import os 4 | import sys 5 | 6 | 7 | def main(): 8 | """Run administrative tasks.""" 9 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'app.settings') 10 | try: 11 | from django.core.management import execute_from_command_line 12 | except ImportError as exc: 13 | raise ImportError( 14 | "Couldn't import Django. Are you sure it's installed and " 15 | "available on your PYTHONPATH environment variable? Did you " 16 | "forget to activate a virtual environment?" 17 | ) from exc 18 | execute_from_command_line(sys.argv) 19 | 20 | 21 | if __name__ == '__main__': 22 | main() 23 | -------------------------------------------------------------------------------- /tests/durable_objects/test_worker.py: -------------------------------------------------------------------------------- 1 | import requests 2 | 3 | from ..utils import durable_objects_web_server # NOQA 4 | 5 | def test_migrations(durable_objects_web_server): 6 | """Run migrations.""" 7 | response = requests.get(f"{durable_objects_web_server.base_url}/__run_migrations__/") 8 | assert response.status_code == 200 9 | assert response.json() == {"status": "success", "message": "Migrations applied."} 10 | 11 | def test_create_admin(durable_objects_web_server): 12 | """Create an admin user a second time should return different state.""" 13 | response = requests.get(f"{durable_objects_web_server.base_url}/__create_admin__/") 14 | assert response.status_code == 200 15 | assert response.json() == {"status": "info", "message": f"Admin user 'admin' already exists."} 16 | 17 | -------------------------------------------------------------------------------- /templates/d1/wrangler.jsonc: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "node_modules/wrangler/config-schema.json", 3 | "name": "django-on-workers-with-d1", 4 | "main": "src/index.py", 5 | "compatibility_flags": [ 6 | "python_workers" 7 | ], 8 | "compatibility_date": "2025-11-25", 9 | "assets": { 10 | "directory": "./staticfiles" 11 | }, 12 | "build": { 13 | "command": "WORKERS_CI=1 python src/manage.py collectstatic --noinput" 14 | }, 15 | "python_modules": { 16 | "exclude": [ 17 | "**/*.pyc", 18 | "**/__pycache__", 19 | "**/*.po", 20 | "**/*.css", 21 | "**/*.js", 22 | "**/*.svg", 23 | "**/*.png", 24 | "**/*.jpg" 25 | ] 26 | }, 27 | "observability": { 28 | "enabled": true 29 | }, 30 | "d1_databases": [ 31 | { 32 | "binding": "DB", 33 | "database_name": "django-on-workers", 34 | "database_id": "590ad313-29a0-422f-9c9e-1124d9b56ad0" 35 | } 36 | ] 37 | } 38 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Run Pytest 2 | 3 | on: [pull_request] 4 | 5 | jobs: 6 | test: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | matrix: 10 | python-version: ["3.12"] 11 | 12 | steps: 13 | - name: Check out code 14 | uses: actions/checkout@v6 15 | 16 | - name: Set up Python ${{ matrix.python-version }} 17 | uses: actions/setup-python@v6 18 | with: 19 | python-version: ${{ matrix.python-version }} 20 | 21 | - name: Set up Node.js 22 | uses: actions/setup-node@v6 23 | with: 24 | node-version: '22' # Specify a Node.js version 25 | 26 | - name: Install pnpm 27 | run: npm install -g pnpm 28 | 29 | - name: Install uv 30 | run: pip install uv 31 | 32 | - name: Install dependencies 33 | run: pnpm install 34 | 35 | - name: Setup tests 36 | run: pnpm run setup-test 37 | 38 | - name: Run tests 39 | run: pnpm run test 40 | -------------------------------------------------------------------------------- /templates/durable-objects/wrangler.jsonc: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "node_modules/wrangler/config-schema.json", 3 | "name": "django-on-workers-with-durable-objects", 4 | "main": "src/index.py", 5 | "compatibility_flags": [ 6 | "python_workers" 7 | ], 8 | "compatibility_date": "2025-11-25", 9 | "assets": { 10 | "directory": "./staticfiles" 11 | }, 12 | "build": { 13 | "command": "WORKERS_CI=1 python src/manage.py collectstatic --noinput" 14 | }, 15 | "python_modules": { 16 | "exclude": [ 17 | "**/*.pyc", 18 | "**/__pycache__", 19 | "**/*.po", 20 | "**/*.css", 21 | "**/*.js", 22 | "**/*.svg", 23 | "**/*.png", 24 | "**/*.jpg" 25 | ] 26 | }, 27 | "observability": { 28 | "enabled": true 29 | }, 30 | "durable_objects": { 31 | "bindings": [ 32 | { 33 | "name": "DO_STORAGE", 34 | "class_name": "DjangoDO" 35 | } 36 | ] 37 | }, 38 | "migrations": [ 39 | { 40 | "tag": "v1", 41 | "new_sqlite_classes": [ 42 | "DjangoDO" 43 | ] 44 | } 45 | ] 46 | } 47 | -------------------------------------------------------------------------------- /tests/servers/r2/wrangler.jsonc: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "node_modules/wrangler/config-schema.json", 3 | "name": "django-cf-r2-test-server", 4 | "main": "src/index.py", 5 | "compatibility_flags": [ 6 | "python_workers" 7 | ], 8 | "compatibility_date": "2025-11-25", 9 | "assets": { 10 | "directory": "./staticfiles" 11 | }, 12 | "build": { 13 | "command": "WORKERS_CI=1 python src/manage.py collectstatic --noinput" 14 | }, 15 | "python_modules": { 16 | "exclude": [ 17 | "**/*.pyc", 18 | "**/__pycache__", 19 | "**/*.po", 20 | "**/*.css", 21 | "**/*.js", 22 | "**/*.svg", 23 | "**/*.png", 24 | "**/*.jpg" 25 | ] 26 | }, 27 | "observability": { 28 | "enabled": true 29 | }, 30 | "d1_databases": [ 31 | { 32 | "binding": "DB", 33 | "database_name": "django-on-workers", 34 | "database_id": "590ad313-29a0-422f-9c9e-1124d9b56ad0" 35 | } 36 | ], 37 | "r2_buckets": [ 38 | { 39 | "binding": "BUCKET", 40 | "bucket_name": "django-test-bucket", 41 | "preview_bucket_name": "django-test-bucket-preview" 42 | } 43 | ] 44 | } 45 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Gabriel Massadas 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /templates/d1/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | 3 | logs 4 | _.log 5 | npm-debug.log_ 6 | yarn-debug.log* 7 | yarn-error.log* 8 | lerna-debug.log* 9 | .pnpm-debug.log* 10 | 11 | # Diagnostic reports (https://nodejs.org/api/report.html) 12 | 13 | report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json 14 | 15 | # Runtime data 16 | 17 | pids 18 | _.pid 19 | _.seed 20 | \*.pid.lock 21 | 22 | # Dependency directories 23 | 24 | node_modules/ 25 | jspm_packages/ 26 | 27 | # TypeScript cache 28 | 29 | \*.tsbuildinfo 30 | 31 | # Optional npm cache directory 32 | 33 | .npm 34 | 35 | # Optional eslint cache 36 | 37 | .eslintcache 38 | 39 | # Optional stylelint cache 40 | 41 | .stylelintcache 42 | 43 | # Optional REPL history 44 | 45 | .node_repl_history 46 | 47 | # Output of 'npm pack' 48 | 49 | \*.tgz 50 | 51 | # dotenv environment variable files 52 | 53 | .env 54 | .env.development.local 55 | .env.test.local 56 | .env.production.local 57 | .env.local 58 | 59 | # public 60 | 61 | # Stores VSCode versions used for testing VSCode extensions 62 | 63 | .vscode-test 64 | 65 | # wrangler project 66 | 67 | .dev.vars 68 | .wrangler/ 69 | .idea 70 | node_modules 71 | staticfiles 72 | python_modules 73 | .venv 74 | .venv-workers 75 | -------------------------------------------------------------------------------- /templates/durable-objects/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | 3 | logs 4 | _.log 5 | npm-debug.log_ 6 | yarn-debug.log* 7 | yarn-error.log* 8 | lerna-debug.log* 9 | .pnpm-debug.log* 10 | 11 | # Diagnostic reports (https://nodejs.org/api/report.html) 12 | 13 | report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json 14 | 15 | # Runtime data 16 | 17 | pids 18 | _.pid 19 | _.seed 20 | \*.pid.lock 21 | 22 | # Dependency directories 23 | 24 | node_modules/ 25 | jspm_packages/ 26 | 27 | # TypeScript cache 28 | 29 | \*.tsbuildinfo 30 | 31 | # Optional npm cache directory 32 | 33 | .npm 34 | 35 | # Optional eslint cache 36 | 37 | .eslintcache 38 | 39 | # Optional stylelint cache 40 | 41 | .stylelintcache 42 | 43 | # Optional REPL history 44 | 45 | .node_repl_history 46 | 47 | # Output of 'npm pack' 48 | 49 | \*.tgz 50 | 51 | # dotenv environment variable files 52 | 53 | .env 54 | .env.development.local 55 | .env.test.local 56 | .env.production.local 57 | .env.local 58 | 59 | # public 60 | 61 | # Stores VSCode versions used for testing VSCode extensions 62 | 63 | .vscode-test 64 | 65 | # wrangler project 66 | 67 | .dev.vars 68 | .wrangler/ 69 | .idea 70 | node_modules 71 | staticfiles 72 | python_modules 73 | .venv-workerse 74 | .venv 75 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools >= 77.0.3"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "django-cf" 7 | version = "0.2.5" 8 | authors = [ 9 | { name="Gabriel Massadas" }, 10 | ] 11 | dependencies = [ 12 | 'sqlparse', 13 | ] 14 | description = "django-cf is a package that integrates Django with Cloudflare products" 15 | readme = "README.md" 16 | license = "MIT" 17 | requires-python = ">=3.12" 18 | classifiers = [ 19 | "Development Status :: 3 - Alpha", 20 | "Framework :: Django", 21 | "Framework :: Django :: 5.0", 22 | "Operating System :: OS Independent", 23 | "Programming Language :: Python", 24 | "Programming Language :: Python :: 3", 25 | "Programming Language :: Python :: 3.12" 26 | ] 27 | 28 | [project.optional-dependencies] 29 | dev = [ 30 | "pytest", 31 | "requests", 32 | "ruff", 33 | "django", 34 | ] 35 | 36 | [project.urls] 37 | "Homepage" = "https://github.com/G4brym/django-cf" 38 | "Bug Reports" = "https://github.com/G4brym/django-cf/issues" 39 | "Source" = "https://github.com/G4brym/django-cf" 40 | 41 | [tool.setuptools.packages.find] 42 | where = ["."] 43 | include = ["django_cf", "django_cf.*"] 44 | exclude = ["tests*", "docs*", "templates*", ".github*", "*.__pycache__"] 45 | 46 | [tool.pytest.ini_options] 47 | minversion = "6.0" 48 | addopts = "-ra -q" 49 | testpaths = [ 50 | "tests", 51 | ] 52 | -------------------------------------------------------------------------------- /templates/d1/src/templates/blog/post_detail.html: -------------------------------------------------------------------------------- 1 | {% extends 'blog/base.html' %} 2 | 3 | {% block title %}{{ post.title }}{% endblock %} 4 | 5 | {% block content %} 6 | 27 | {% endblock %} 28 | -------------------------------------------------------------------------------- /templates/durable-objects/src/templates/blog/post_detail.html: -------------------------------------------------------------------------------- 1 | {% extends 'blog/base.html' %} 2 | 3 | {% block title %}{{ post.title }}{% endblock %} 4 | 5 | {% block content %} 6 | 27 | {% endblock %} 28 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "django-cf", 3 | "version": "1.0.0", 4 | "description": "`django-cf` is a Python package that seamlessly integrates your Django applications with various Cloudflare services. Utilize the power of Cloudflare's global network for your Django projects.", 5 | "homepage": "https://github.com/G4brym/django-cf", 6 | "bugs": { 7 | "url": "https://github.com/G4brym/django-cf/issues" 8 | }, 9 | "private": "true", 10 | "repository": { 11 | "type": "git", 12 | "url": "git+https://github.com/G4brym/django-cf.git" 13 | }, 14 | "license": "MIT", 15 | "scripts": { 16 | "setup-durable-objects": "cd templates/durable-objects && npm run dependencies && rm -rf python_modules/django_cf && cp -r ../../django_cf python_modules/django_cf", 17 | "setup-d1": "cd templates/d1 && npm run dependencies && rm -rf python_modules/django_cf && cp -r ../../django_cf python_modules/django_cf", 18 | "setup-test-servers": "cd tests/servers/r2 && npm run dependencies && rm -rf python_modules/django_cf && cp -r ../../../django_cf python_modules/django_cf", 19 | "setup-test": "pip install -e .[dev] && npm run setup-durable-objects && npm run setup-d1 && npm run setup-test-servers", 20 | "test": "pytest", 21 | "upgrade-templates": "cd templates/durable-objects && uv add django-cf --upgrade && cd ../d1 && uv add django-cf --upgrade", 22 | "build": "rm -rf dist/ && python3 -m build", 23 | "publish": "python3 -m twine upload dist/*" 24 | }, 25 | "devDependencies": { 26 | "wrangler": "^4.22.0" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /templates/d1/src/blog/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.1.2 on 2025-07-06 13:30 2 | 3 | import django.utils.timezone 4 | from django.db import migrations, models 5 | 6 | 7 | def insert_dummy_blog_post(apps, schema_editor): 8 | Post = apps.get_model('blog', 'Post') 9 | 10 | # Insert the record 11 | Post.objects.create( 12 | title='Example Post', 13 | content="Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged. It was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum passages, and more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum." 14 | ) 15 | 16 | 17 | class Migration(migrations.Migration): 18 | 19 | initial = True 20 | 21 | dependencies = [ 22 | ] 23 | 24 | operations = [ 25 | migrations.CreateModel( 26 | name='Post', 27 | fields=[ 28 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 29 | ('title', models.CharField(max_length=200)), 30 | ('content', models.TextField()), 31 | ('pub_date', models.DateTimeField(default=django.utils.timezone.now)), 32 | ], 33 | ), 34 | migrations.RunPython( 35 | code=insert_dummy_blog_post, 36 | ), 37 | ] 38 | -------------------------------------------------------------------------------- /templates/durable-objects/src/blog/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.1.2 on 2025-07-06 13:30 2 | 3 | import django.utils.timezone 4 | from django.db import migrations, models 5 | 6 | 7 | def insert_dummy_blog_post(apps, schema_editor): 8 | Post = apps.get_model('blog', 'Post') 9 | 10 | # Insert the record 11 | Post.objects.create( 12 | title='Example Post', 13 | content="Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged. It was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum passages, and more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum." 14 | ) 15 | 16 | 17 | class Migration(migrations.Migration): 18 | 19 | initial = True 20 | 21 | dependencies = [ 22 | ] 23 | 24 | operations = [ 25 | migrations.CreateModel( 26 | name='Post', 27 | fields=[ 28 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 29 | ('title', models.CharField(max_length=200)), 30 | ('content', models.TextField()), 31 | ('pub_date', models.DateTimeField(default=django.utils.timezone.now)), 32 | ], 33 | ), 34 | migrations.RunPython( 35 | code=insert_dummy_blog_post, 36 | ), 37 | ] 38 | -------------------------------------------------------------------------------- /django_cf/db/backends/do/base.py: -------------------------------------------------------------------------------- 1 | from .storage import get_storage 2 | from ...base_engine import CFDatabaseWrapper, CFResult 3 | 4 | 5 | class DatabaseWrapper(CFDatabaseWrapper): 6 | vendor = "cloudflare_durable_objects" 7 | display_name = "DO" 8 | binding: str 9 | 10 | def get_connection_params(self): 11 | return {} 12 | 13 | 14 | def process_query(self, query, params=None): 15 | if params is None: 16 | query = query.replace('%s', '?') 17 | else: 18 | new_params = [] 19 | for param in params: 20 | if param is None: 21 | query = query.replace('%s', 'null', 1) 22 | else: 23 | new_params.append(param) 24 | query = query.replace('%s', '?', 1) 25 | 26 | params = new_params 27 | 28 | 29 | if self.cursor()._defer_foreign_keys: 30 | return f''' 31 | PRAGMA defer_foreign_keys = on 32 | 33 | {query} 34 | 35 | PRAGMA defer_foreign_keys = off 36 | ''' 37 | 38 | return query, params 39 | 40 | def run_query(self, query, params=None) -> CFResult: 41 | proc_query, params = self.process_query(query, params) 42 | 43 | db = get_storage() 44 | 45 | if params: 46 | stmt = db.exec(proc_query, *params); 47 | else: 48 | stmt = db.exec(proc_query); 49 | 50 | try: 51 | response = stmt.raw().toArray().to_py() 52 | result = CFResult.from_object(query, params, response, stmt.rowsRead, stmt.rowsWritten) 53 | except: 54 | from js import Error 55 | Error.stackTraceLimit = 1e10 56 | raise Error(Error.new().stack) 57 | 58 | return result 59 | -------------------------------------------------------------------------------- /templates/d1/src/templates/blog/post_list.html: -------------------------------------------------------------------------------- 1 | {% extends 'blog/base.html' %} 2 | 3 | {% block title %}Blog Posts{% endblock %} 4 | 5 | {% block content %} 6 |
7 |

Latest Posts

8 |

Explore articles and updates from our blog

9 |
10 | 11 | {% if posts %} 12 |
13 | {% for post in posts %} 14 | 32 | {% endfor %} 33 |
34 | {% else %} 35 |
36 |
📝
37 |

No Posts Yet

38 |

There are no blog posts available at the moment.

39 | 40 | Back to Home → 41 | 42 |
43 | {% endif %} 44 | {% endblock %} 45 | -------------------------------------------------------------------------------- /templates/durable-objects/src/templates/blog/post_list.html: -------------------------------------------------------------------------------- 1 | {% extends 'blog/base.html' %} 2 | 3 | {% block title %}Blog Posts{% endblock %} 4 | 5 | {% block content %} 6 |
7 |

Latest Posts

8 |

Explore articles and updates from our blog

9 |
10 | 11 | {% if posts %} 12 |
13 | {% for post in posts %} 14 | 32 | {% endfor %} 33 |
34 | {% else %} 35 |
36 |
📝
37 |

No Posts Yet

38 |

There are no blog posts available at the moment.

39 | 40 | Back to Home → 41 | 42 |
43 | {% endif %} 44 | {% endblock %} 45 | -------------------------------------------------------------------------------- /templates/d1/src/app/urls.py: -------------------------------------------------------------------------------- 1 | """ 2 | URL configuration for app project. 3 | 4 | The `urlpatterns` list routes URLs to views. For more information please see: 5 | https://docs.djangoproject.com/en/5.1/topics/http/urls/ 6 | Examples: 7 | Function views 8 | 1. Add an import: from my_app import views 9 | 2. Add a URL to urlpatterns: path('', views.home, name='home') 10 | Class-based views 11 | 1. Add an import: from other_app.views import Home 12 | 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') 13 | Including another URLconf 14 | 1. Import the include() function: from django.urls import include, path 15 | 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) 16 | """ 17 | from django.contrib import admin 18 | from django.contrib.auth import get_user_model 19 | from django.core.management import call_command 20 | from django.http import JsonResponse 21 | from django.urls import path, include 22 | from django.contrib.auth.decorators import user_passes_test 23 | 24 | def is_superuser(user): 25 | return user.is_authenticated and user.is_superuser 26 | 27 | # @user_passes_test(is_superuser) 28 | def create_admin_view(request): 29 | User = get_user_model() 30 | username = 'admin' # Or get from request, config, etc. 31 | email = 'admin@example.com' # Or get from request, config, etc. 32 | # IMPORTANT: Change this password or manage it securely! 33 | password = 'password' 34 | 35 | if not User.objects.filter(username=username).exists(): 36 | User.objects.create_superuser(username, email, password) 37 | return JsonResponse({"status": "success", "message": f"Admin user '{username}' created."}) 38 | else: 39 | return JsonResponse({"status": "info", "message": f"Admin user '{username}' already exists."}) 40 | 41 | # @user_passes_test(is_superuser) 42 | def run_migrations_view(request): 43 | try: 44 | call_command("migrate") 45 | return JsonResponse({"status": "success", "message": "Migrations applied."}) 46 | except Exception as e: 47 | return JsonResponse({"status": "error", "message": e.__str__()}, status=500) 48 | 49 | 50 | urlpatterns = [ 51 | path('', include('blog.urls')), 52 | 53 | path('admin/', admin.site.urls), 54 | # Management endpoints - secure these appropriately for your application 55 | path('__create_admin__/', create_admin_view, name='create_admin'), 56 | path('__run_migrations__/', run_migrations_view, name='run_migrations'), 57 | ] 58 | -------------------------------------------------------------------------------- /templates/durable-objects/src/app/urls.py: -------------------------------------------------------------------------------- 1 | """ 2 | URL configuration for app project. 3 | 4 | The `urlpatterns` list routes URLs to views. For more information please see: 5 | https://docs.djangoproject.com/en/5.1/topics/http/urls/ 6 | Examples: 7 | Function views 8 | 1. Add an import: from my_app import views 9 | 2. Add a URL to urlpatterns: path('', views.home, name='home') 10 | Class-based views 11 | 1. Add an import: from other_app.views import Home 12 | 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') 13 | Including another URLconf 14 | 1. Import the include() function: from django.urls import include, path 15 | 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) 16 | """ 17 | from django.contrib import admin 18 | from django.contrib.auth import get_user_model 19 | from django.core.management import call_command 20 | from django.http import JsonResponse 21 | from django.urls import path, include 22 | from django.contrib.auth.decorators import user_passes_test 23 | 24 | def is_superuser(user): 25 | return user.is_authenticated and user.is_superuser 26 | 27 | # @user_passes_test(is_superuser) 28 | def create_admin_view(request): 29 | User = get_user_model() 30 | username = 'admin' # Or get from request, config, etc. 31 | email = 'admin@example.com' # Or get from request, config, etc. 32 | # IMPORTANT: Change this password or manage it securely! 33 | password = 'password' 34 | 35 | if not User.objects.filter(username=username).exists(): 36 | User.objects.create_superuser(username, email, password) 37 | return JsonResponse({"status": "success", "message": f"Admin user '{username}' created."}) 38 | else: 39 | return JsonResponse({"status": "info", "message": f"Admin user '{username}' already exists."}) 40 | 41 | # @user_passes_test(is_superuser) 42 | def run_migrations_view(request): 43 | try: 44 | call_command("migrate") 45 | return JsonResponse({"status": "success", "message": "Migrations applied."}) 46 | except Exception as e: 47 | return JsonResponse({"status": "error", "message": str(e)}, status=500) 48 | 49 | 50 | urlpatterns = [ 51 | path('', include('blog.urls')), 52 | 53 | path('admin/', admin.site.urls), 54 | # Management endpoints - secure these appropriately for your application 55 | path('__create_admin__/', create_admin_view, name='create_admin'), 56 | path('__run_migrations__/', run_migrations_view, name='run_migrations'), 57 | ] 58 | -------------------------------------------------------------------------------- /django_cf/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | from io import BytesIO 3 | 4 | 5 | async def handle_wsgi(request, app): 6 | os.environ.setdefault('DJANGO_ALLOW_ASYNC_UNSAFE', 'false') 7 | from js import Object, Response, URL, console 8 | 9 | url = URL.new(request.url) 10 | assert url.protocol[-1] == ":" 11 | scheme = url.protocol[:-1] 12 | path = url.pathname 13 | assert "?".startswith(url.search[0:1]) 14 | query_string = url.search[1:] 15 | method = str(request.method).upper() 16 | 17 | host = url.host.split(':')[0] 18 | 19 | wsgi_request = { 20 | 'REQUEST_METHOD': method, 21 | 'PATH_INFO': path, 22 | 'QUERY_STRING': query_string, 23 | 'SERVER_NAME': host, 24 | 'SERVER_PORT': url.port, 25 | 'SERVER_PROTOCOL': 'HTTP/1.1', 26 | 'wsgi.input': BytesIO(b''), 27 | 'wsgi.errors': console.error, 28 | 'wsgi.version': (1, 0), 29 | 'wsgi.multithread': False, 30 | 'wsgi.multiprocess': False, 31 | 'wsgi.run_once': True, 32 | 'wsgi.url_scheme': scheme, 33 | } 34 | 35 | if request.headers.get('content-type'): 36 | wsgi_request['CONTENT_TYPE'] = request.headers.get('content-type') 37 | 38 | if request.headers.get('content-length'): 39 | wsgi_request['CONTENT_LENGTH'] = request.headers.get('content-length') 40 | 41 | for header in request.headers.items(): 42 | wsgi_request[f'HTTP_{header[0].upper()}'] = header[1] 43 | 44 | if method in ['POST', 'PUT', 'PATCH']: 45 | body = (await request._js_request.arrayBuffer()).to_bytes() 46 | wsgi_request['wsgi.input'] = BytesIO(body) 47 | 48 | def start_response(status_str, response_headers): 49 | nonlocal status, headers 50 | status = status_str 51 | headers = response_headers 52 | 53 | resp = app(wsgi_request, start_response) 54 | status = resp.status_code 55 | headers = resp.headers 56 | 57 | final_response = Response.new( 58 | resp.content.decode('utf-8'), headers=Object.fromEntries(headers.items()), status=status 59 | ) 60 | 61 | for k, v in resp.cookies.items(): 62 | value = str(v) 63 | final_response.headers.set('Set-Cookie', value.replace('Set-Cookie: ', '', 1)); 64 | 65 | return final_response 66 | 67 | 68 | class DjangoCF: 69 | def get_app(self): 70 | raise NotImplementedError("Please implement implement get_app in your django_cf worker") 71 | 72 | async def fetch(self, request): 73 | return await handle_wsgi(request, self.get_app()) 74 | 75 | 76 | class DjangoCFDurableObject: 77 | def get_app(self): 78 | raise NotImplementedError("Please implement implement get_app in your django_cf worker") 79 | 80 | def __init__(self, ctx, env): 81 | self.ctx = ctx 82 | self.env = env 83 | 84 | from django_cf.db.backends.do.storage import set_storage 85 | set_storage(self.ctx.storage.sql) 86 | 87 | def fetch(self, request): 88 | return handle_wsgi(request, self.get_app()) 89 | -------------------------------------------------------------------------------- /templates/d1/src/templates/blog/base.html: -------------------------------------------------------------------------------- 1 | {% load static %} 2 | 3 | 4 | 5 | 6 | 7 | {% block title %}Blog{% endblock %} - django-cf 8 | 9 | 23 | 24 | 25 | 26 |
27 | 44 |
45 | 46 | 47 |
48 | {% block content %} 49 | {% endblock %} 50 |
51 | 52 | 53 |
54 |
55 |
56 |

Built with ❤️ for serverless Django

57 |
58 | GitHub 59 | D1 Docs 60 | Django Docs 61 |
62 |
63 |
64 |
65 | 66 | 67 | -------------------------------------------------------------------------------- /django_cf/db/backends/d1/base.py: -------------------------------------------------------------------------------- 1 | from django.core.exceptions import ImproperlyConfigured 2 | 3 | from ...base_engine import CFDatabaseWrapper, is_read_only_query, CFResult 4 | 5 | 6 | class DatabaseWrapper(CFDatabaseWrapper): 7 | vendor = "cloudflare_d1" 8 | display_name = "D1" 9 | binding: str 10 | 11 | def get_connection_params(self): 12 | settings_dict = self.settings_dict 13 | if not settings_dict["CLOUDFLARE_BINDING"]: 14 | raise ImproperlyConfigured( 15 | "settings.DATABASES is improperly configured. " 16 | "Please supply the CLOUDFLARE_BINDING value." 17 | ) 18 | kwargs = { 19 | "binding": settings_dict["CLOUDFLARE_BINDING"], 20 | } 21 | return kwargs 22 | 23 | def get_new_connection(self, conn_params): 24 | self.binding = conn_params["binding"] 25 | return super().get_new_connection(conn_params) 26 | 27 | def __init__(self, *args): 28 | super().__init__(*args) 29 | 30 | try: 31 | from workers import import_from_javascript 32 | from pyodide.ffi import run_sync 33 | self.import_from_javascript = import_from_javascript 34 | self.run_sync = run_sync 35 | except ImportError as e: 36 | print(e) 37 | raise Exception("Code not running inside a worker!") 38 | 39 | 40 | def process_query(self, query, params=None): 41 | if params is None: 42 | query = query.replace('%s', '?') 43 | else: 44 | new_params = [] 45 | for param in params: 46 | if param is None: 47 | query = query.replace('%s', 'null', 1) 48 | else: 49 | new_params.append(param) 50 | query = query.replace('%s', '?', 1) 51 | 52 | params = new_params 53 | 54 | 55 | if self.cursor()._defer_foreign_keys: 56 | return f''' 57 | PRAGMA defer_foreign_keys = on 58 | 59 | {query} 60 | 61 | PRAGMA defer_foreign_keys = off 62 | ''' 63 | 64 | return query, params 65 | 66 | def run_query(self, query, params=None) -> CFResult: 67 | proc_query, params = self.process_query(query, params) 68 | 69 | cf_workers = self.import_from_javascript("cloudflare:workers") 70 | db = getattr(cf_workers.env, self.binding) 71 | 72 | if params: 73 | stmt = db.prepare(proc_query).bind(*params); 74 | else: 75 | stmt = db.prepare(proc_query); 76 | 77 | read_only = is_read_only_query(proc_query) 78 | try: 79 | if read_only: 80 | response = self.run_sync(stmt.raw()).to_py() 81 | result = CFResult.from_object(query, params, response, len(response), 0) 82 | else: 83 | response = self.run_sync(stmt.all()) 84 | result = CFResult.from_object(query, params, response.results.to_py(), response.meta.rows_read, response.meta.rows_written, 85 | response.meta.last_row_id) 86 | except: 87 | from js import Error 88 | Error.stackTraceLimit = 1e10 89 | raise Error(Error.new().stack) 90 | 91 | return result 92 | -------------------------------------------------------------------------------- /templates/durable-objects/src/templates/blog/base.html: -------------------------------------------------------------------------------- 1 | {% load static %} 2 | 3 | 4 | 5 | 6 | 7 | {% block title %}Blog{% endblock %} - django-cf 8 | 9 | 23 | 24 | 25 | 26 |
27 | 44 |
45 | 46 | 47 |
48 | {% block content %} 49 | {% endblock %} 50 |
51 | 52 | 53 |
54 |
55 |
56 |

Built with ❤️ for serverless Django

57 |
58 | GitHub 59 | D1 Docs 60 | Django Docs 61 |
62 |
63 |
64 |
65 | 66 | 67 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm.fming.dev/#use-with-ide 110 | .pdm.toml 111 | 112 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 113 | __pypackages__/ 114 | 115 | # Celery stuff 116 | celerybeat-schedule 117 | celerybeat.pid 118 | 119 | # SageMath parsed files 120 | *.sage.py 121 | 122 | # Environments 123 | .env 124 | .venv 125 | env/ 126 | venv/ 127 | ENV/ 128 | env.bak/ 129 | venv.bak/ 130 | 131 | # Spyder project settings 132 | .spyderproject 133 | .spyproject 134 | 135 | # Rope project settings 136 | .ropeproject 137 | 138 | # mkdocs documentation 139 | /site 140 | 141 | # mypy 142 | .mypy_cache/ 143 | .dmypy.json 144 | dmypy.json 145 | 146 | # Pyre type checker 147 | .pyre/ 148 | 149 | # pytype static type analyzer 150 | .pytype/ 151 | 152 | # Cython debug symbols 153 | cython_debug/ 154 | 155 | .idea/ 156 | node_modules 157 | -------------------------------------------------------------------------------- /tests/utils.py: -------------------------------------------------------------------------------- 1 | import os 2 | import signal 3 | import socket 4 | import subprocess 5 | import time 6 | 7 | import pytest 8 | import requests 9 | 10 | 11 | def get_free_port(): 12 | """Find an available port on localhost.""" 13 | with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: 14 | s.bind(("localhost", 0)) 15 | return s.getsockname()[1] 16 | 17 | 18 | class WorkerFixture: 19 | def __init__(self, migrate=True): 20 | self.process = None 21 | self.port = None 22 | self.base_url = None 23 | self.migrate = migrate 24 | 25 | def start(self, worker_dir): 26 | """Start the worker in a subprocess.""" 27 | self.port = get_free_port() 28 | self.base_url = f"http://localhost:{self.port}" 29 | 30 | # Clean up previous wrangler state 31 | wrangler_state_dir = os.path.join(worker_dir, '.wrangler') 32 | if os.path.exists(wrangler_state_dir): 33 | subprocess.run(['rm', '-rf', wrangler_state_dir], cwd=worker_dir, check=True) 34 | 35 | cmd_parts = ["npx", "wrangler", "dev", "--port", str(self.port)] 36 | self.process = subprocess.Popen( 37 | cmd_parts, 38 | cwd=worker_dir, # Run npx from the worker directory 39 | preexec_fn=os.setsid, # So we can kill the process group later 40 | ) 41 | 42 | # Wait for server to start 43 | self._wait_for_server() 44 | 45 | if self.migrate: 46 | requests.get(f"{self.base_url}/__run_migrations__/") 47 | requests.get(f"{self.base_url}/__create_admin__/") 48 | 49 | return self 50 | 51 | def _wait_for_server(self, max_retries=10, retry_interval=1): 52 | """Wait until the server is responding to requests.""" 53 | for _ in range(max_retries): 54 | try: 55 | response = requests.get(self.base_url, timeout=20) 56 | if response.status_code < 500: # Accept any non-server error response 57 | return 58 | except requests.exceptions.RequestException: 59 | pass 60 | 61 | time.sleep(retry_interval) 62 | 63 | # If we got here, the server didn't start properly 64 | self.stop() 65 | raise Exception(f"worker failed to start on port {self.port}") 66 | 67 | def stop(self): 68 | """Stop the worker.""" 69 | if self.process: 70 | # Kill the process group (including any child processes) 71 | os.killpg(os.getpgid(self.process.pid), signal.SIGTERM) 72 | self.process = None 73 | 74 | 75 | @pytest.fixture(scope="session") 76 | def durable_objects_web_server(): 77 | """Pytest fixture that starts the worker for the entire test session.""" 78 | worker_dir = os.path.join(os.path.dirname(__file__), '..', 'templates', 'durable-objects') 79 | server = WorkerFixture() 80 | server.start(worker_dir) 81 | 82 | yield server 83 | server.stop() 84 | 85 | 86 | @pytest.fixture(scope="session") 87 | def d1_web_server(): 88 | """Pytest fixture that starts the worker for the entire test session.""" 89 | worker_dir = os.path.join(os.path.dirname(__file__), '..', 'templates', 'd1') 90 | server = WorkerFixture() 91 | server.start(worker_dir) 92 | 93 | yield server 94 | server.stop() 95 | 96 | 97 | @pytest.fixture(scope="session") 98 | def r2_web_server(): 99 | """Pytest fixture that starts the R2 test server for the entire test session.""" 100 | worker_dir = os.path.join(os.path.dirname(__file__), 'servers', 'r2') 101 | server = WorkerFixture(False) 102 | server.start(worker_dir) 103 | 104 | yield server 105 | server.stop() 106 | -------------------------------------------------------------------------------- /templates/d1/src/app/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for app project. 3 | 4 | Generated by 'django-admin startproject' using Django 5.1.2. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/5.1/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/5.1/ref/settings/ 11 | """ 12 | import os 13 | from pathlib import Path 14 | 15 | # Build paths inside the project like this: BASE_DIR / 'subdir'. 16 | BASE_DIR = Path(__file__).resolve().parent.parent 17 | 18 | 19 | # Quick-start development settings - unsuitable for production 20 | # See https://docs.djangoproject.com/en/5.1/howto/deployment/checklist/ 21 | 22 | # SECURITY WARNING: keep the secret key used in production secret! 23 | SECRET_KEY = 'django-insecure-n+hxsvm6_5^5kx2xxe0@oq6@9laf0%&%)@du(pn6-(3enfoha_' 24 | 25 | # SECURITY WARNING: don't run with debug turned on in production! 26 | DEBUG = True 27 | 28 | ALLOWED_HOSTS = ['*'] 29 | 30 | 31 | # Application definition 32 | 33 | INSTALLED_APPS = [ 34 | 'django.contrib.admin', 35 | 'django.contrib.auth', 36 | 'django.contrib.contenttypes', 37 | 'django.contrib.sessions', 38 | 'django.contrib.messages', 39 | 'django.contrib.staticfiles', 40 | 41 | 'blog', 42 | ] 43 | 44 | MIDDLEWARE = [ 45 | 'django.middleware.security.SecurityMiddleware', 46 | 'django.contrib.sessions.middleware.SessionMiddleware', 47 | 'django.middleware.common.CommonMiddleware', 48 | 'django.middleware.csrf.CsrfViewMiddleware', 49 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 50 | 'django.contrib.messages.middleware.MessageMiddleware', 51 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 52 | ] 53 | 54 | ROOT_URLCONF = 'app.urls' 55 | 56 | TEMPLATES = [ 57 | { 58 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 59 | 'DIRS': [ 60 | BASE_DIR.joinpath('templates') 61 | ], 62 | 'APP_DIRS': True, 63 | 'OPTIONS': { 64 | 'context_processors': [ 65 | 'django.template.context_processors.debug', 66 | 'django.template.context_processors.request', 67 | 'django.contrib.auth.context_processors.auth', 68 | 'django.contrib.messages.context_processors.messages', 69 | ], 70 | }, 71 | }, 72 | ] 73 | 74 | WSGI_APPLICATION = 'app.wsgi.application' 75 | 76 | 77 | # Database 78 | # https://docs.djangoproject.com/en/5.1/ref/settings/#databases 79 | 80 | # Workers CI is missing the sqlite3 module, to build the staticfiles, we must disable the engine 81 | if os.getenv('WORKERS_CI') == "1": 82 | DATABASES = {} 83 | else: 84 | DATABASES = { 85 | 'default': { 86 | 'ENGINE': 'django_cf.db.backends.d1', 87 | # 'CLOUDFLARE_BINDING' should match the binding name in your wrangler.toml 88 | 'CLOUDFLARE_BINDING': 'DB', 89 | } 90 | } 91 | 92 | # Password validation 93 | # https://docs.djangoproject.com/en/5.1/ref/settings/#auth-password-validators 94 | 95 | AUTH_PASSWORD_VALIDATORS = [ 96 | { 97 | 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', 98 | }, 99 | { 100 | 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', 101 | }, 102 | { 103 | 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', 104 | }, 105 | { 106 | 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', 107 | }, 108 | ] 109 | 110 | 111 | # Internationalization 112 | # https://docs.djangoproject.com/en/5.1/topics/i18n/ 113 | 114 | LANGUAGE_CODE = 'en-us' 115 | 116 | TIME_ZONE = 'UTC' 117 | 118 | USE_I18N = False 119 | 120 | USE_TZ = False 121 | 122 | 123 | # Static files (CSS, JavaScript, Images) 124 | # https://docs.djangoproject.com/en/5.1/howto/static-files/ 125 | 126 | STATIC_URL = 'static/' 127 | STATIC_ROOT = BASE_DIR.parent.joinpath('staticfiles').joinpath('static') 128 | 129 | # Default primary key field type 130 | # https://docs.djangoproject.com/en/5.1/ref/settings/#default-auto-field 131 | 132 | DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' 133 | -------------------------------------------------------------------------------- /templates/durable-objects/src/app/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for app project. 3 | 4 | Generated by 'django-admin startproject' using Django 5.1.2. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/5.1/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/5.1/ref/settings/ 11 | """ 12 | import os 13 | from pathlib import Path 14 | 15 | # Build paths inside the project like this: BASE_DIR / 'subdir'. 16 | BASE_DIR = Path(__file__).resolve().parent.parent 17 | 18 | 19 | # Quick-start development settings - unsuitable for production 20 | # See https://docs.djangoproject.com/en/5.1/howto/deployment/checklist/ 21 | 22 | # SECURITY WARNING: keep the secret key used in production secret! 23 | SECRET_KEY = 'django-insecure-n+hxsvm6_5^5kx2xxe0@oq6@9laf0%&%)@du(pn6-(3enfoha_' 24 | 25 | # SECURITY WARNING: don't run with debug turned on in production! 26 | DEBUG = True 27 | 28 | ALLOWED_HOSTS = ['*'] 29 | 30 | 31 | # Application definition 32 | 33 | INSTALLED_APPS = [ 34 | 'django.contrib.admin', 35 | 'django.contrib.auth', 36 | 'django.contrib.contenttypes', 37 | 'django.contrib.sessions', 38 | 'django.contrib.messages', 39 | 'django.contrib.staticfiles', 40 | 41 | 'blog', 42 | ] 43 | 44 | MIDDLEWARE = [ 45 | 'django.middleware.security.SecurityMiddleware', 46 | 'django.contrib.sessions.middleware.SessionMiddleware', 47 | 'django.middleware.common.CommonMiddleware', 48 | 'django.middleware.csrf.CsrfViewMiddleware', 49 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 50 | 'django.contrib.messages.middleware.MessageMiddleware', 51 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 52 | ] 53 | 54 | ROOT_URLCONF = 'app.urls' 55 | 56 | TEMPLATES = [ 57 | { 58 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 59 | 'DIRS': [ 60 | BASE_DIR.joinpath('templates') 61 | ], 62 | 'APP_DIRS': True, 63 | 'OPTIONS': { 64 | 'context_processors': [ 65 | 'django.template.context_processors.debug', 66 | 'django.template.context_processors.request', 67 | 'django.contrib.auth.context_processors.auth', 68 | 'django.contrib.messages.context_processors.messages', 69 | ], 70 | }, 71 | }, 72 | ] 73 | 74 | WSGI_APPLICATION = 'app.wsgi.application' 75 | 76 | 77 | # Database 78 | # https://docs.djangoproject.com/en/5.1/ref/settings/#databases 79 | 80 | # Workers CI is missing the sqlite3 module, to build the staticfiles, we must disable the engine 81 | if os.getenv('WORKERS_CI') == "1": 82 | DATABASES = {} 83 | else: 84 | DATABASES = { 85 | 'default': { 86 | 'ENGINE': 'django_cf.db.backends.do', 87 | } 88 | } 89 | 90 | # Password validation 91 | # https://docs.djangoproject.com/en/5.1/ref/settings/#auth-password-validators 92 | 93 | AUTH_PASSWORD_VALIDATORS = [ 94 | { 95 | 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', 96 | }, 97 | { 98 | 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', 99 | }, 100 | { 101 | 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', 102 | }, 103 | { 104 | 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', 105 | }, 106 | ] 107 | 108 | 109 | # Internationalization 110 | # https://docs.djangoproject.com/en/5.1/topics/i18n/ 111 | 112 | LANGUAGE_CODE = 'en-us' 113 | 114 | TIME_ZONE = 'UTC' 115 | 116 | # Enabling translations, requires also enabling the glob "vendor/**/*.mo" in wrangler.jsonc 117 | # This will make your worker bigger than 3MB, thus requiring you a Workers Paid Plan 118 | USE_I18N = False 119 | 120 | USE_TZ = True 121 | 122 | 123 | # Static files (CSS, JavaScript, Images) 124 | # https://docs.djangoproject.com/en/5.1/howto/static-files/ 125 | 126 | STATIC_URL = 'static/' 127 | STATIC_ROOT = BASE_DIR.parent.joinpath('staticfiles').joinpath('static') 128 | 129 | # Default primary key field type 130 | # https://docs.djangoproject.com/en/5.1/ref/settings/#default-auto-field 131 | 132 | DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' 133 | -------------------------------------------------------------------------------- /tests/servers/r2/src/app/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for app project. 3 | 4 | Generated by 'django-admin startproject' using Django 5.1.2. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/5.1/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/5.1/ref/settings/ 11 | """ 12 | import os 13 | from pathlib import Path 14 | 15 | # Build paths inside the project like this: BASE_DIR / 'subdir'. 16 | BASE_DIR = Path(__file__).resolve().parent.parent 17 | 18 | 19 | # Quick-start development settings - unsuitable for production 20 | # See https://docs.djangoproject.com/en/5.1/howto/deployment/checklist/ 21 | 22 | # SECURITY WARNING: keep the secret key used in production secret! 23 | SECRET_KEY = 'django-insecure-n+hxsvm6_5^5kx2xxe0@oq6@9laf0%&%)@du(pn6-(3enfoha_' 24 | 25 | # SECURITY WARNING: don't run with debug turned on in production! 26 | DEBUG = True 27 | 28 | ALLOWED_HOSTS = ['*'] 29 | 30 | 31 | # Application definition 32 | 33 | INSTALLED_APPS = [ 34 | 'django.contrib.admin', 35 | 'django.contrib.auth', 36 | 'django.contrib.contenttypes', 37 | 'django.contrib.sessions', 38 | 'django.contrib.messages', 39 | 'django.contrib.staticfiles', 40 | ] 41 | 42 | MIDDLEWARE = [ 43 | 'django.middleware.security.SecurityMiddleware', 44 | 'django.contrib.sessions.middleware.SessionMiddleware', 45 | 'django.middleware.common.CommonMiddleware', 46 | # 'django.middleware.csrf.CsrfViewMiddleware', 47 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 48 | 'django.contrib.messages.middleware.MessageMiddleware', 49 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 50 | ] 51 | 52 | ROOT_URLCONF = 'app.urls' 53 | 54 | TEMPLATES = [ 55 | { 56 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 57 | 'DIRS': [ 58 | BASE_DIR.joinpath('templates') 59 | ], 60 | 'APP_DIRS': True, 61 | 'OPTIONS': { 62 | 'context_processors': [ 63 | 'django.template.context_processors.debug', 64 | 'django.template.context_processors.request', 65 | 'django.contrib.auth.context_processors.auth', 66 | 'django.contrib.messages.context_processors.messages', 67 | ], 68 | }, 69 | }, 70 | ] 71 | 72 | WSGI_APPLICATION = 'app.wsgi.application' 73 | 74 | 75 | # Database 76 | # https://docs.djangoproject.com/en/5.1/ref/settings/#databases 77 | 78 | # Workers CI is missing the sqlite3 module, to build the staticfiles, we must disable the engine 79 | DATABASES = { 80 | 'default': { 81 | 'ENGINE': 'django.db.backends.sqlite3', 82 | 'NAME': ':memory:', 83 | }, 84 | } 85 | 86 | # Storage configuration 87 | # https://docs.djangoproject.com/en/5.1/ref/settings/#std:setting-STORAGES 88 | 89 | STORAGES = { 90 | "default": { 91 | "BACKEND": "django_cf.storage.R2Storage", 92 | "OPTIONS": { 93 | "binding": "BUCKET", 94 | "location": "", 95 | "allow_overwrite": True, 96 | } 97 | }, 98 | "staticfiles": { 99 | "BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage", 100 | }, 101 | } 102 | 103 | # Password validation 104 | # https://docs.djangoproject.com/en/5.1/ref/settings/#auth-password-validators 105 | 106 | AUTH_PASSWORD_VALIDATORS = [ 107 | { 108 | 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', 109 | }, 110 | { 111 | 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', 112 | }, 113 | { 114 | 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', 115 | }, 116 | { 117 | 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', 118 | }, 119 | ] 120 | 121 | 122 | # Internationalization 123 | # https://docs.djangoproject.com/en/5.1/topics/i18n/ 124 | 125 | LANGUAGE_CODE = 'en-us' 126 | 127 | TIME_ZONE = 'UTC' 128 | 129 | USE_I18N = False 130 | 131 | USE_TZ = False 132 | 133 | 134 | # Static files (CSS, JavaScript, Images) 135 | # https://docs.djangoproject.com/en/5.1/howto/static-files/ 136 | 137 | STATIC_URL = 'static/' 138 | STATIC_ROOT = BASE_DIR.parent.joinpath('staticfiles').joinpath('static') 139 | 140 | # Default primary key field type 141 | # https://docs.djangoproject.com/en/5.1/ref/settings/#default-auto-field 142 | 143 | DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' 144 | -------------------------------------------------------------------------------- /tests/servers/r2/src/app/urls.py: -------------------------------------------------------------------------------- 1 | """ 2 | URL configuration for app project. 3 | 4 | The `urlpatterns` list routes URLs to views. For more information please see: 5 | https://docs.djangoproject.com/en/5.1/topics/http/urls/ 6 | Examples: 7 | Function views 8 | 1. Add an import: from my_app import views 9 | 2. Add a URL to urlpatterns: path('', views.home, name='home') 10 | Class-based views 11 | 1. Add an import: from other_app.views import Home 12 | 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') 13 | Including another URLconf 14 | 1. Import the include() function: from django.urls import include, path 15 | 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) 16 | """ 17 | from django.contrib import admin 18 | from django.contrib.auth import get_user_model 19 | from django.core.management import call_command 20 | from django.http import JsonResponse, HttpResponse, HttpResponseNotFound 21 | from django.urls import path, include 22 | from django.contrib.auth.decorators import user_passes_test 23 | from django.core.files.storage import default_storage 24 | from django.core.files.base import ContentFile 25 | 26 | def is_superuser(user): 27 | return user.is_authenticated and user.is_superuser 28 | 29 | # R2 Storage Test Endpoints 30 | def r2_upload_view(request): 31 | """Test endpoint for uploading files to R2.""" 32 | if request.method == 'POST': 33 | try: 34 | uploaded_file = request.FILES.get('file') 35 | path = request.POST.get('path') 36 | 37 | if not uploaded_file or not path: 38 | return JsonResponse({"status": "error", "message": "Missing file or path"}, status=400) 39 | 40 | # Save file to R2 41 | saved_path = default_storage.save(path, ContentFile(uploaded_file.read())) 42 | 43 | return JsonResponse({"status": "success", "path": saved_path}) 44 | except Exception as e: 45 | print(str(e)) 46 | return JsonResponse({"status": "error", "message": str(e)}, status=500) 47 | return JsonResponse({"status": "error", "message": "Method not allowed"}, status=405) 48 | 49 | 50 | def r2_download_view(request): 51 | """Test endpoint for downloading files from R2.""" 52 | path = request.GET.get('path') 53 | 54 | if not path: 55 | return JsonResponse({"status": "error", "message": "Missing path parameter"}, status=400) 56 | 57 | try: 58 | if not default_storage.exists(path): 59 | return HttpResponseNotFound("File not found") 60 | 61 | with default_storage.open(path, 'rb') as f: 62 | content = f.read() 63 | 64 | return HttpResponse(content, content_type='application/octet-stream') 65 | except Exception as e: 66 | print(str(e)) 67 | return JsonResponse({"status": "error", "message": str(e)}, status=500) 68 | 69 | 70 | def r2_exists_view(request): 71 | """Test endpoint for checking if a file exists in R2.""" 72 | path = request.GET.get('path') 73 | 74 | if not path: 75 | return JsonResponse({"status": "error", "message": "Missing path parameter"}, status=400) 76 | 77 | try: 78 | exists = default_storage.exists(path) 79 | return JsonResponse({"exists": exists}) 80 | except Exception as e: 81 | print(str(e)) 82 | return JsonResponse({"status": "error", "message": str(e)}, status=500) 83 | 84 | 85 | def r2_delete_view(request): 86 | """Test endpoint for deleting files from R2.""" 87 | if request.method == 'POST': 88 | try: 89 | path = request.POST.get('path') 90 | 91 | if not path: 92 | return JsonResponse({"status": "error", "message": "Missing path parameter"}, status=400) 93 | 94 | default_storage.delete(path) 95 | return JsonResponse({"status": "success"}) 96 | except Exception as e: 97 | print(str(e)) 98 | return JsonResponse({"status": "error", "message": str(e)}, status=500) 99 | return JsonResponse({"status": "error", "message": "Method not allowed"}, status=405) 100 | 101 | 102 | def r2_listdir_view(request): 103 | """Test endpoint for listing files in R2.""" 104 | path = request.GET.get('path', '') 105 | 106 | try: 107 | directories, files = default_storage.listdir(path) 108 | return JsonResponse({"directories": list(directories), "files": list(files)}) 109 | except Exception as e: 110 | print(str(e)) 111 | return JsonResponse({"status": "error", "message": str(e)}, status=500) 112 | 113 | 114 | def r2_size_view(request): 115 | """Test endpoint for getting file size from R2.""" 116 | path = request.GET.get('path') 117 | 118 | if not path: 119 | return JsonResponse({"status": "error", "message": "Missing path parameter"}, status=400) 120 | 121 | try: 122 | size = default_storage.size(path) 123 | return JsonResponse({"size": size}) 124 | except Exception as e: 125 | print(str(e)) 126 | return JsonResponse({"status": "error", "message": str(e)}, status=500) 127 | 128 | 129 | urlpatterns = [ 130 | path('admin/', admin.site.urls), 131 | 132 | # R2 Storage Test endpoints - for testing only 133 | path('__r2_upload__/', r2_upload_view, name='r2_upload'), 134 | path('__r2_download__/', r2_download_view, name='r2_download'), 135 | path('__r2_exists__/', r2_exists_view, name='r2_exists'), 136 | path('__r2_delete__/', r2_delete_view, name='r2_delete'), 137 | path('__r2_listdir__/', r2_listdir_view, name='r2_listdir'), 138 | path('__r2_size__/', r2_size_view, name='r2_size'), 139 | ] 140 | -------------------------------------------------------------------------------- /AGENTS.md: -------------------------------------------------------------------------------- 1 | ## Working with the `django-cf` Repository 2 | 3 | This document provides guidance for AI agents working on the `django-cf` repository. 4 | 5 | ### Project Overview 6 | 7 | `django-cf` is a Python package that integrates Django with Cloudflare services. It provides database backends for Cloudflare D1 and Durable Objects, plus middleware for Cloudflare Access authentication. Two complete example projects are provided in the `templates/` directory: one using D1 and one using Durable Objects. 8 | 9 | ### Running Tests 10 | 11 | The project uses `npm` for Node.js dependencies and `uv` for Python dependencies. The test execution process is defined in `.github/workflows/ci.yml` and can be replicated locally as follows: 12 | 13 | 1. **Install Dependencies**: Navigate to the project root and run: 14 | ```bash 15 | npm install 16 | uv sync 17 | ``` 18 | This installs both Node.js and Python dependencies required for the project. 19 | 2. **Run Tests**: Execute the tests using: 20 | ```bash 21 | pytest 22 | ``` 23 | This command will execute `pytest` which uses the `WorkerFixture` in `tests/test_worker.py`. The fixture manages the Cloudflare worker lifecycle (starting `wrangler dev`) for the integration tests. 24 | 25 | **Test Structure Details**: 26 | * Integration tests are located in the `tests/` directory. This directory must contain an `__init__.py` file to be treated as a package. 27 | * Tests use the `requests` library to make HTTP calls to the worker. 28 | * The `web_server` fixture (defined in `tests/test_worker.py` and available to all test files) provides the base URL for the running worker. 29 | * Special management URLs (e.g., `/__run_migrations__/`, `/__create_admin__/` in `templates/durable-objects/src/app/urls.py`) are available for test setup. These create an admin user with username `admin` and password `password`. 30 | 31 | ### Coding Conventions 32 | 33 | * Follow PEP 8 for Python code. 34 | * Use Ruff for linting and formatting if configured. 35 | * Keep tests focused and independent where possible. The admin tests in `tests/test_admin.py` rely on the admin user created via the management endpoint. 36 | 37 | ### Key Files and Directories 38 | 39 | * `pyproject.toml`: Defines project metadata, Python dependencies, and build settings. 40 | * `AGENTS.md`: This file. 41 | * `django_cf/`: Main package containing database backends and middleware. 42 | * `db/backends/d1/`: D1 database backend implementation. 43 | * `db/backends/do/`: Durable Objects database backend implementation. 44 | * `middleware/`: Cloudflare Access authentication middleware. 45 | * `templates/d1/`: Complete Django example using D1 database. 46 | * `package.json`: Node.js dependencies and npm scripts (`dev`, `deploy`). 47 | * `wrangler.jsonc`: Wrangler configuration. 48 | * `pyproject.toml`: Python project configuration. 49 | * `src/index.py`: Worker entrypoint. 50 | * `src/app/`: Django project directory. 51 | * `templates/durable-objects/`: Complete Django example using Durable Objects. 52 | * `package.json`: Node.js dependencies and npm scripts (`dev`, `deploy`). 53 | * `wrangler.jsonc`: Wrangler configuration. 54 | * `pyproject.toml`: Python project configuration. 55 | * `src/index.py`: Worker entrypoint. 56 | * `src/app/`: Django project directory. 57 | * `tests/`: Contains integration tests. 58 | * `__init__.py`: Makes `tests/` a Python package. 59 | * `test_worker.py`: Defines the worker fixture and basic setup tests. 60 | * `test_admin.py`: Contains tests for the Django admin interface. 61 | 62 | ### Workflow for Future Agents 63 | 64 | 1. **Understand the Setup**: The `django-cf` package provides backends and middleware for Django on Cloudflare Workers. Two complete templates are provided for reference. 65 | 2. **Dependencies First**: Always ensure both Python (`uv sync`) and Node (`npm install`) dependencies are up to date. 66 | 3. **Database Backends**: The package supports D1 (via `django_cf.db.backends.d1`) and Durable Objects (via `django_cf.db.backends.do`). 67 | 4. **Write Tests**: For new features, add corresponding integration tests in the `tests/` directory. 68 | 5. **Run Tests**: Execute `pytest` to verify changes. Debug any worker startup issues by examining output from the `WorkerFixture`. 69 | 6. **Update AGENTS.md**: If you make significant changes to the testing process, dependency management, or project structure, update this document. 70 | 71 | ### Troubleshooting Tests 72 | 73 | * **`worker failed to start`**: 74 | * Check the `stdout` and `stderr` printed by the test fixture for errors from `wrangler dev`. 75 | * Ensure `npm install` and `uv sync` completed successfully. 76 | * Verify that the port selected by `get_free_port()` is indeed available. 77 | * **`ImportError: attempted relative import with no known parent package`**: Ensure `tests/__init__.py` exists. 78 | * **`no tests ran` when running `pytest`**: Ensure you are running `pytest` from the root directory of the project. If you are in a subdirectory (e.g., `templates/d1/` or `templates/durable-objects/`), `pytest` might not discover the tests in the `tests/` directory. Running `pytest tests/` from the root should correctly discover and run the tests. 79 | * **CSRF token issues**: The `get_csrf_token` helper in `tests/test_admin.py` is a basic string parser. If Django admin templates change significantly, this helper might need updating (e.g., to use a proper HTML parser). 80 | * **Database backend issues**: Remember that both D1 and Durable Objects have transactions disabled. All queries are committed immediately with no rollback capability. 81 | 82 | ### Continuous Integration 83 | 84 | A GitHub Actions workflow is configured in `.github/workflows/ci.yml`. This workflow automatically runs all tests using `pytest` on every push to any branch. It tests against Python versions 3.10, 3.11, and 3.12. 85 | 86 | By following these guidelines, agents can contribute effectively to this repository. 87 | -------------------------------------------------------------------------------- /templates/d1/README.md: -------------------------------------------------------------------------------- 1 | # Django D1 Template for Cloudflare Workers 2 | 3 | This template provides a starting point for running a Django application on Cloudflare Workers, utilizing Cloudflare D1 for serverless SQL database. 4 | 5 | [![Deploy to Cloudflare](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/G4brym/django-cf/tree/main/templates/d1) 6 | 7 | ## Overview 8 | 9 | This template is pre-configured to: 10 | - Use `django-cf` to bridge Django with Cloudflare's environment. 11 | - Employ Cloudflare D1 as the primary data store through the `django_cf.db.backends.d1` database engine. 12 | - Include a basic Django project structure within the `src/` directory. 13 | - Provide example worker entrypoint (`src/index.py`). 14 | 15 | ## Project Structure 16 | 17 | ``` 18 | template-root/ 19 | |-> src/ 20 | | |-> manage.py # Django management script 21 | | |-> index.py # Cloudflare Worker entrypoint 22 | | |-> app/ # Your Django project (rename as needed) 23 | | | |-> settings.py # Django settings, configured for D1 24 | | | |-> urls.py # Django URLs, includes management endpoints 25 | | | |-> wsgi.py # WSGI application 26 | | |-> your_django_apps/ # Add your Django apps here 27 | | |-> vendor/ # Project dependencies (managed by vendor.txt) 28 | |-> staticfiles/ # Collected static files (after build) 29 | |-> .gitignore 30 | |-> package.json # For Node.js dependencies like wrangler 31 | |-> uv.lock # Python dependencies lock file 32 | |-> pyproject.toml # Python project configuration 33 | |-> wrangler.jsonc # Wrangler configuration 34 | ``` 35 | 36 | ## Setup and Deployment 37 | 38 | 1. **Install Dependencies:** 39 | Ensure you have Node.js, npm, and Python installed. Then: 40 | 41 | ```bash 42 | # Install Node.js dependencies 43 | npm install 44 | 45 | # Install Python dependencies 46 | uv sync 47 | ``` 48 | 49 | If you don't have `uv` installed, install it first: 50 | ```bash 51 | pip install uv 52 | ``` 53 | 54 | 2. **Configure `wrangler.jsonc`:** 55 | Review and update `wrangler.jsonc` for your project. Key sections: 56 | * `name`: Your worker's name. 57 | * `compatibility_date`: Keep this up-to-date. 58 | * `d1_databases`: 59 | * `binding`: The name used to access the D1 database in your worker (e.g., "DB"). 60 | * `database_name`: The name of your D1 database in the Cloudflare dashboard. 61 | * `database_id`: The ID of your D1 database. 62 | 63 | Example `d1_databases` configuration in `wrangler.jsonc`: 64 | ```jsonc 65 | { 66 | "d1_databases": [ 67 | { 68 | "binding": "DB", 69 | "database_name": "my-django-db", 70 | "database_id": "your-d1-database-id-here" 71 | } 72 | ] 73 | } 74 | ``` 75 | 76 | 3. **Django Settings (`src/app/settings.py`):** 77 | The template should be configured to use D1 binding: 78 | ```python 79 | # src/app/settings.py 80 | DATABASES = { 81 | 'default': { 82 | 'ENGINE': 'django_cf.db.backends.d1', 83 | # This name 'DB' must match the 'binding' in your wrangler.jsonc d1_databases section 84 | 'CLOUDFLARE_BINDING': 'DB', 85 | } 86 | } 87 | ``` 88 | 89 | 4. **Worker Entrypoint (`src/index.py`):** 90 | This file contains the main worker handler for your Django application. 91 | ```python 92 | from workers import WorkerEntrypoint 93 | from django_cf import DjangoCF 94 | 95 | class Default(DjangoCF, WorkerEntrypoint): 96 | async def get_app(self): 97 | from app.wsgi import application 98 | return application 99 | ``` 100 | 101 | 5. **Run Development Server:** 102 | ```bash 103 | npm run dev 104 | ``` 105 | This starts the local development server using Wrangler. 106 | 107 | 6. **Deploy to Cloudflare:** 108 | ```bash 109 | npm run deploy 110 | ``` 111 | This command installs system dependencies and deploys your worker to Cloudflare. 112 | 113 | ## Running Management Commands 114 | 115 | For D1, you can use the special management endpoints provided in the template: 116 | 117 | * **`/__run_migrations__/`**: Triggers the `migrate` command. 118 | * **`/__create_admin__/`**: Creates a superuser (username: 'admin', password: 'password'). 119 | 120 | These endpoints are defined in `src/app/urls.py` and are protected by `user_passes_test(is_superuser)`. This means you must first create an admin user and be logged in as that user to access these endpoints. 121 | 122 | **Initial Admin User Creation:** 123 | For the very first admin user creation, you might need to temporarily remove the `@user_passes_test(is_superuser)` decorator from `create_admin_view` in `src/app/urls.py`, deploy, access `/__create_admin__/`, and then reinstate the decorator and redeploy. Alternatively, modify the `create_admin_view` to accept a secure token or other mechanism for the initial setup if direct unauthenticated access is undesirable. 124 | 125 | **Accessing the Endpoints:** 126 | Once deployed and an admin user exists (and you are logged in as them): 127 | - Visit `https://your-worker-url.com/__run_migrations__/` to apply migrations. 128 | - Visit `https://your-worker-url.com/__create_admin__/` to create the admin user if needed. 129 | 130 | Check the JSON response in your browser to see the status of the command. 131 | 132 | ## Development Notes 133 | 134 | * **D1 Limitations:** 135 | * **Transactions are disabled** for D1. Every query is committed immediately. This is a fundamental aspect of D1. 136 | * The D1 backend has some limitations compared to traditional SQLite or other SQL databases. Many advanced ORM features or direct SQL functions (especially those used in Django Admin) might not be fully supported. Refer to the `django-cf` README and official Cloudflare D1 documentation. 137 | * Django Admin functionality might be limited. 138 | * **Local Testing with D1:** 139 | * Wrangler allows local development and can simulate D1 access. `npx wrangler dev --remote` can connect to your actual D1 database for more accurate testing. 140 | * **Security:** 141 | * The management command endpoints are protected by Django's `user_passes_test(is_superuser)`. Ensure they are properly secured before deploying to production. 142 | * Protect your Cloudflare credentials and API tokens. 143 | 144 | --- 145 | *For more details on `django-cf` features and configurations, refer to the main [django-cf GitHub repository](https://github.com/G4brym/django-cf).* 146 | -------------------------------------------------------------------------------- /templates/durable-objects/README.md: -------------------------------------------------------------------------------- 1 | # Django Durable Objects Template for Cloudflare Workers 2 | 3 | This template provides a starting point for running a Django application on Cloudflare Workers, utilizing Cloudflare Durable Objects for stateful data persistence. 4 | 5 | [![Deploy to Cloudflare](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/G4brym/django-cf/tree/main/templates/durable-objects) 6 | 7 | ## Overview 8 | 9 | This template is pre-configured to: 10 | - Use `django-cf` to bridge Django with Cloudflare's environment. 11 | - Employ Durable Objects as the primary data store through the `django_cf.db.backends.do` database engine. 12 | - Include a basic Django project structure within the `src/` directory. 13 | - Provide example worker entrypoint (`src/worker.py`) and Durable Object class. 14 | 15 | ## Project Structure 16 | 17 | ``` 18 | template-root/ 19 | |-> src/ 20 | | |-> manage.py # Django management script 21 | | |-> worker.py # Cloudflare Worker entrypoint & Durable Object class 22 | | |-> app/ # Your Django project (rename as needed) 23 | | | |-> settings.py # Django settings, configured for DO 24 | | | |-> urls.py # Django URLs, includes management endpoints 25 | | | |-> wsgi.py # WSGI application 26 | | |-> your_django_apps/ # Add your Django apps here 27 | | |-> vendor/ # Project dependencies (managed by vendor.txt) 28 | |-> staticfiles/ # Collected static files (after build) 29 | |-> .gitignore 30 | |-> package.json # For Node.js dependencies like wrangler 31 | |-> package-lock.json 32 | |-> requirements-dev.txt # Python dev dependencies 33 | |-> vendor.txt # Pip requirements for vendoring 34 | |-> wrangler.jsonc # Wrangler configuration 35 | ``` 36 | 37 | ## Setup and Deployment 38 | 39 | 1. **Install Dependencies:** 40 | Ensure you have Node.js, npm, and Python installed. Then: 41 | 42 | ```bash 43 | # Install Node.js dependencies 44 | npm install 45 | 46 | # Install Python dependencies 47 | uv sync 48 | ``` 49 | 50 | If you don't have `uv` installed, install it first: 51 | ```bash 52 | pip install uv 53 | ``` 54 | 55 | 2. **Configure `wrangler.jsonc`:** 56 | Review and update `wrangler.jsonc` for your project. Key sections: 57 | * `name`: Your worker's name. 58 | * `compatibility_date`: Keep this up-to-date. 59 | * `durable_objects`: 60 | * `bindings`: Ensure `name` (e.g., "DO_STORAGE") and `class_name` (e.g., "DjangoDO") are correctly set. 61 | * `migrations`: Define migrations for your Durable Object classes if you add or change them. 62 | 63 | 3. **Django Settings (`src/app/settings.py`):** 64 | The template is pre-configured to use Durable Objects: 65 | ```python 66 | # src/app/settings.py 67 | DATABASES = { 68 | 'default': { 69 | 'ENGINE': 'django_cf.db.backends.do', 70 | } 71 | } 72 | ``` 73 | 74 | 4. **Worker Entrypoint (`src/worker.py`):** 75 | This file contains your Durable Object class (`DjangoDO`) and the main `on_fetch` handler. 76 | ```python 77 | from django_cf import DjangoCFDurableObject 78 | from workers import DurableObject # Standard Cloudflare DurableObject base 79 | 80 | class DjangoDO(DjangoCFDurableObject, DurableObject): 81 | def get_app(self): 82 | # Your Django project's WSGI application (ensure 'app' matches your project name) 83 | from app.wsgi import application 84 | return application 85 | # You can add custom methods to your Durable Object here 86 | 87 | # Main fetch handler for the worker 88 | async def on_fetch(request, env): 89 | # This example routes all requests to a single DO instance (singleton). 90 | # For multi-tenant apps, derive 'name' from the request (e.g., user ID, path segment). 91 | # The DO_STORAGE binding name here must match what's in wrangler.jsonc 92 | do_id = env.DO_STORAGE.idFromName("singleton_instance") 93 | stub = env.DO_STORAGE.get(do_id) 94 | return await stub.fetch(request) # Forward the request to the Durable Object 95 | ``` 96 | 97 | 5. **Run Development Server:** 98 | ```bash 99 | npm run dev 100 | ``` 101 | This starts the local development server using Wrangler. 102 | 103 | 6. **Deploy to Cloudflare:** 104 | ```bash 105 | npm run deploy 106 | ``` 107 | This command installs system dependencies and deploys your worker to Cloudflare. 108 | 109 | ## Running Management Commands 110 | 111 | Since this template uses Durable Objects, standard Django management commands like `migrate` or `createsuperuser` cannot be run directly against the deployed DO instances from your local machine in the same way you might with D1 via the API. 112 | 113 | This template provides special URL endpoints to trigger these commands on the deployed worker. **These endpoints must be properly secured.** 114 | 115 | * **`/__run_migrations__/`**: Triggers the `migrate` command. 116 | * **`/__create_admin__/`**: Creates a superuser (username: 'admin', email: 'admin@example.com', password: 'yoursecurepassword' - change this!). 117 | 118 | These endpoints are defined in `src/app/urls.py` and are protected by `user_passes_test(is_superuser)`. This means you must first create an admin user and be logged in as that user to access these endpoints. 119 | 120 | **Initial Admin User Creation:** 121 | For the very first admin user creation, you might need to temporarily remove the `@user_passes_test(is_superuser)` decorator from `create_admin_view` in `src/app/urls.py`, deploy, access `/__create_admin__/`, and then reinstate the decorator and redeploy. Alternatively, modify the `create_admin_view` to accept a secure token or other mechanism for the initial setup if direct unauthenticated access is undesirable. 122 | 123 | **Accessing the Endpoints:** 124 | Once deployed and an admin user exists (and you are logged in as them via Django's admin interface, for example): 125 | - Visit `https://your-worker-url.com/__run_migrations__/` to apply migrations. 126 | - Visit `https://your-worker-url.com/__create_admin__/` to (re)create the admin user if needed. 127 | 128 | Check the JSON response in your browser to see the status of the command. 129 | 130 | ## Development Notes 131 | 132 | * **Durable Object State:** Remember that Durable Objects maintain state. If you make significant changes to your Django models or how state is managed, you might need to consider strategies for migrating or resetting DO state. This can involve clearing storage for specific DO instances or implementing data migration logic within your DO or Django application. 133 | * **Local Testing:** Testing Durable Objects locally can be challenging. Wrangler provides some local development capabilities, but fully emulating the Cloudflare environment can be complex. 134 | * **Security:** Pay close attention to the security of your management endpoints. The provided `user_passes_test` is a basic protection; consider more robust authentication/authorization if needed, especially for production environments. 135 | ``` 136 | -------------------------------------------------------------------------------- /tests/r2/test_storage.py: -------------------------------------------------------------------------------- 1 | import requests 2 | 3 | from ..utils import r2_web_server # NOQA 4 | 5 | 6 | def test_r2_upload_file(r2_web_server): 7 | """Test uploading a file to R2 storage.""" 8 | upload_url = f"{r2_web_server.base_url}/__r2_upload__/" 9 | 10 | # Upload a test file 11 | files = {'file': ('test.txt', b'Hello, R2 Storage!', 'text/plain')} 12 | data = {'path': 'test_uploads/test.txt'} 13 | response = requests.post(upload_url, files=files, data=data, timeout=10) 14 | 15 | assert response.status_code == 200 16 | result = response.json() 17 | assert result['status'] == 'success' 18 | assert 'path' in result 19 | assert result['path'] == 'test_uploads/test.txt' 20 | 21 | 22 | def test_r2_download_file(r2_web_server): 23 | """Test downloading a file from R2 storage.""" 24 | # First upload a file 25 | upload_url = f"{r2_web_server.base_url}/__r2_upload__/" 26 | files = {'file': ('download_test.txt', b'Download test content', 'text/plain')} 27 | data = {'path': 'test_downloads/download_test.txt'} 28 | upload_response = requests.post(upload_url, files=files, data=data, timeout=10) 29 | assert upload_response.status_code == 200 30 | 31 | # Now download it 32 | download_url = f"{r2_web_server.base_url}/__r2_download__/" 33 | params = {'path': 'test_downloads/download_test.txt'} 34 | response = requests.get(download_url, params=params, timeout=10) 35 | 36 | assert response.status_code == 200 37 | assert response.content == b'Download test content' 38 | 39 | 40 | def test_r2_file_exists(r2_web_server): 41 | """Test checking if a file exists in R2 storage.""" 42 | # Upload a file first 43 | upload_url = f"{r2_web_server.base_url}/__r2_upload__/" 44 | files = {'file': ('exists_test.txt', b'Exists test', 'text/plain')} 45 | data = {'path': 'test_exists/exists_test.txt'} 46 | upload_response = requests.post(upload_url, files=files, data=data, timeout=10) 47 | assert upload_response.status_code == 200 48 | 49 | # Check if file exists 50 | exists_url = f"{r2_web_server.base_url}/__r2_exists__/" 51 | params = {'path': 'test_exists/exists_test.txt'} 52 | response = requests.get(exists_url, params=params, timeout=10) 53 | 54 | assert response.status_code == 200 55 | result = response.json() 56 | assert result['exists'] is True 57 | 58 | # Check non-existent file 59 | params = {'path': 'test_exists/nonexistent.txt'} 60 | response = requests.get(exists_url, params=params, timeout=10) 61 | 62 | assert response.status_code == 200 63 | result = response.json() 64 | assert result['exists'] is False 65 | 66 | 67 | def test_r2_delete_file(r2_web_server): 68 | """Test deleting a file from R2 storage.""" 69 | # Upload a file first 70 | upload_url = f"{r2_web_server.base_url}/__r2_upload__/" 71 | files = {'file': ('delete_test.txt', b'Delete me', 'text/plain')} 72 | data = {'path': 'test_delete/delete_test.txt'} 73 | upload_response = requests.post(upload_url, files=files, data=data, timeout=10) 74 | assert upload_response.status_code == 200 75 | 76 | # Verify it exists 77 | exists_url = f"{r2_web_server.base_url}/__r2_exists__/" 78 | params = {'path': 'test_delete/delete_test.txt'} 79 | exists_response = requests.get(exists_url, params=params, timeout=10) 80 | assert exists_response.json()['exists'] is True 81 | 82 | # Delete the file 83 | delete_url = f"{r2_web_server.base_url}/__r2_delete__/" 84 | data = {'path': 'test_delete/delete_test.txt'} 85 | response = requests.post(delete_url, data=data, timeout=10) 86 | 87 | assert response.status_code == 200 88 | result = response.json() 89 | assert result['status'] == 'success' 90 | 91 | # Verify it no longer exists 92 | exists_response = requests.get(exists_url, params=params, timeout=10) 93 | assert exists_response.json()['exists'] is False 94 | 95 | 96 | def test_r2_list_files(r2_web_server): 97 | """Test listing files in R2 storage.""" 98 | # Upload multiple files 99 | upload_url = f"{r2_web_server.base_url}/__r2_upload__/" 100 | 101 | test_files = [ 102 | ('test_list/file1.txt', b'Content 1'), 103 | ('test_list/file2.txt', b'Content 2'), 104 | ('test_list/subdir/file3.txt', b'Content 3'), 105 | ] 106 | 107 | for path, content in test_files: 108 | files = {'file': (path.split('/')[-1], content, 'text/plain')} 109 | data = {'path': path} 110 | response = requests.post(upload_url, files=files, data=data, timeout=10) 111 | assert response.status_code == 200 112 | 113 | # List files in test_list directory 114 | listdir_url = f"{r2_web_server.base_url}/__r2_listdir__/" 115 | params = {'path': 'test_list'} 116 | response = requests.get(listdir_url, params=params, timeout=10) 117 | 118 | assert response.status_code == 200 119 | result = response.json() 120 | assert 'directories' in result 121 | assert 'files' in result 122 | assert 'subdir' in result['directories'] 123 | assert 'file1.txt' in result['files'] 124 | assert 'file2.txt' in result['files'] 125 | 126 | 127 | def test_r2_file_size(r2_web_server): 128 | """Test getting file size from R2 storage.""" 129 | # Upload a file with known size 130 | upload_url = f"{r2_web_server.base_url}/__r2_upload__/" 131 | content = b'This content has a specific size.' 132 | files = {'file': ('size_test.txt', content, 'text/plain')} 133 | data = {'path': 'test_size/size_test.txt'} 134 | upload_response = requests.post(upload_url, files=files, data=data, timeout=10) 135 | assert upload_response.status_code == 200 136 | 137 | # Get file size 138 | size_url = f"{r2_web_server.base_url}/__r2_size__/" 139 | params = {'path': 'test_size/size_test.txt'} 140 | response = requests.get(size_url, params=params, timeout=10) 141 | 142 | assert response.status_code == 200 143 | result = response.json() 144 | assert result['size'] == len(content) 145 | 146 | 147 | def test_r2_content_type_preservation(r2_web_server): 148 | """Test that content type is preserved when uploading files.""" 149 | upload_url = f"{r2_web_server.base_url}/__r2_upload__/" 150 | 151 | # Upload a JSON file 152 | files = {'file': ('data.json', b'{"key": "value"}', 'application/json')} 153 | data = {'path': 'test_content_type/data.json'} 154 | response = requests.post(upload_url, files=files, data=data, timeout=10) 155 | assert response.status_code == 200 156 | 157 | # Download and check content type (if your endpoint returns it) 158 | download_url = f"{r2_web_server.base_url}/__r2_download__/" 159 | params = {'path': 'test_content_type/data.json'} 160 | response = requests.get(download_url, params=params, timeout=10) 161 | 162 | assert response.status_code == 200 163 | assert response.content == b'{"key": "value"}' 164 | 165 | 166 | def test_r2_overwrite_file(r2_web_server): 167 | """Test overwriting an existing file in R2 storage.""" 168 | upload_url = f"{r2_web_server.base_url}/__r2_upload__/" 169 | path = 'test_overwrite/overwrite_test.txt' 170 | 171 | # Upload original file 172 | files = {'file': ('overwrite_test.txt', b'Original content', 'text/plain')} 173 | data = {'path': path} 174 | response = requests.post(upload_url, files=files, data=data, timeout=10) 175 | assert response.status_code == 200 176 | 177 | # Download and verify original content 178 | download_url = f"{r2_web_server.base_url}/__r2_download__/" 179 | params = {'path': path} 180 | response = requests.get(download_url, params=params, timeout=10) 181 | assert response.content == b'Original content' 182 | 183 | # Overwrite with new content 184 | files = {'file': ('overwrite_test.txt', b'New content', 'text/plain')} 185 | data = {'path': path} 186 | response = requests.post(upload_url, files=files, data=data, timeout=10) 187 | assert response.status_code == 200 188 | 189 | # Download and verify new content 190 | response = requests.get(download_url, params=params, timeout=10) 191 | assert response.content == b'New content' 192 | 193 | 194 | def test_r2_empty_file(r2_web_server): 195 | """Test uploading and downloading an empty file.""" 196 | upload_url = f"{r2_web_server.base_url}/__r2_upload__/" 197 | 198 | # Upload empty file 199 | files = {'file': ('empty.txt', b'', 'text/plain')} 200 | data = {'path': 'test_empty/empty.txt'} 201 | response = requests.post(upload_url, files=files, data=data, timeout=10) 202 | assert response.status_code == 200 203 | 204 | # Download and verify it's empty 205 | download_url = f"{r2_web_server.base_url}/__r2_download__/" 206 | params = {'path': 'test_empty/empty.txt'} 207 | response = requests.get(download_url, params=params, timeout=10) 208 | assert response.status_code == 200 209 | assert response.content == b'' 210 | 211 | # Check size 212 | size_url = f"{r2_web_server.base_url}/__r2_size__/" 213 | response = requests.get(size_url, params=params, timeout=10) 214 | assert response.status_code == 200 215 | assert response.json()['size'] == 0 216 | -------------------------------------------------------------------------------- /tests/d1/test_admin.py: -------------------------------------------------------------------------------- 1 | import requests 2 | 3 | from ..utils import d1_web_server # NOQA 4 | 5 | def get_csrf_token(client, url): 6 | """Fetches a page and extracts the CSRF token from a form.""" 7 | try: 8 | response = client.get(url, timeout=10) 9 | response.raise_for_status() 10 | body = response.text 11 | start_str = 'name="csrfmiddlewaretoken" value="' 12 | start_idx = body.find(start_str) 13 | if start_idx == -1: 14 | # Fallback for Django 5.0+ where token might be in a script tag or different structure 15 | # This is a simplified search and might need adjustment 16 | start_str_script = '"csrfToken":"' 17 | start_idx_script = body.find(start_str_script) 18 | if start_idx_script != -1: 19 | start_idx = start_idx_script + len(start_str_script) 20 | end_idx = body.find('"', start_idx) 21 | if end_idx != -1: 22 | return body[start_idx:end_idx] 23 | raise ValueError("CSRF token not found in form or script.") 24 | 25 | start_idx += len(start_str) 26 | end_idx = body.find('"', start_idx) 27 | if end_idx == -1: 28 | raise ValueError("CSRF token not properly formatted in form.") 29 | return body[start_idx:end_idx] 30 | except requests.exceptions.RequestException as e: 31 | print(f"Error fetching CSRF token from {url}: {e}") 32 | raise 33 | except ValueError as e: 34 | print(f"Error parsing CSRF token from {url}: {e}") 35 | raise 36 | 37 | 38 | def test_admin_login_page_loads(d1_web_server): 39 | """Test that the admin login page loads correctly.""" 40 | admin_url = f"{d1_web_server.base_url}/admin/login/" 41 | response = requests.get(admin_url, timeout=10) 42 | assert response.status_code == 200 43 | assert "Django administration" in response.text 44 | 45 | def test_admin_dashboard_unauthorized_access(d1_web_server): 46 | """Test that accessing the admin dashboard without login redirects to the login page.""" 47 | admin_dashboard_url = f"{d1_web_server.base_url}/admin/" 48 | login_url = f"{d1_web_server.base_url}/admin/login/" 49 | 50 | client = requests.Session() 51 | # First check with allow_redirects=False to see the 302 52 | response_no_redirect = client.get(admin_dashboard_url, timeout=10, allow_redirects=False) 53 | assert response_no_redirect.status_code == 302 54 | location_header = response_no_redirect.headers.get("Location", "") 55 | # The location might be relative /admin/login/?next=/admin/ or absolute 56 | assert "admin/login" in location_header 57 | assert "next=/admin/" in location_header 58 | 59 | # Then check with allow_redirects=True (default) to ensure it lands on the login page 60 | response_followed = client.get(admin_dashboard_url, timeout=10) 61 | assert response_followed.status_code == 200 62 | assert response_followed.url.startswith(login_url) # Final URL should be the login page 63 | assert "Django administration" in response_followed.text # Should show the login page content 64 | 65 | def test_admin_login_successful(d1_web_server): 66 | """Test a successful login to the Django admin.""" 67 | login_url = f"{d1_web_server.base_url}/admin/login/" 68 | admin_dashboard_url = f"{d1_web_server.base_url}/admin/" 69 | client = requests.Session() 70 | 71 | csrf_token = get_csrf_token(client, login_url) 72 | login_data = { 73 | "username": "admin", 74 | "password": "password", 75 | "csrfmiddlewaretoken": csrf_token, 76 | "next": "/admin/", 77 | } 78 | headers = {"Referer": login_url} 79 | response = client.post(login_url, data=login_data, headers=headers, timeout=10) 80 | 81 | assert response.status_code == 200 82 | assert response.url.rstrip('/') == admin_dashboard_url.rstrip('/') 83 | assert "Site administration" in response.text 84 | assert "Log out" in response.text 85 | 86 | 87 | def test_admin_login_failed_wrong_password(d1_web_server): 88 | """Test a failed login attempt with a wrong password.""" 89 | login_url = f"{d1_web_server.base_url}/admin/login/" 90 | client = requests.Session() 91 | csrf_token = get_csrf_token(client, login_url) 92 | login_data = { 93 | "username": "admin", 94 | "password": "wrongpassword", 95 | "csrfmiddlewaretoken": csrf_token, 96 | "next": "/admin/", 97 | } 98 | headers = {"Referer": login_url} 99 | response = client.post(login_url, data=login_data, headers=headers, timeout=10) 100 | 101 | assert response.status_code == 200 102 | assert "Please enter the correct username and password" in response.text 103 | assert "Log out" not in response.text 104 | 105 | def test_admin_login_failed_wrong_username(d1_web_server): 106 | """Test a failed login attempt with a non-existent username.""" 107 | login_url = f"{d1_web_server.base_url}/admin/login/" 108 | client = requests.Session() 109 | csrf_token = get_csrf_token(client, login_url) 110 | login_data = { 111 | "username": "nonexistentuser", 112 | "password": "password", 113 | "csrfmiddlewaretoken": csrf_token, 114 | "next": "/admin/", 115 | } 116 | headers = {"Referer": login_url} 117 | response = client.post(login_url, data=login_data, headers=headers, timeout=10) 118 | 119 | assert response.status_code == 200 120 | assert "Please enter the correct username and password" in response.text 121 | assert "Log out" not in response.text 122 | 123 | def test_admin_logout(d1_web_server): 124 | """Test logging out from the Django admin.""" 125 | login_url = f"{d1_web_server.base_url}/admin/login/" 126 | logout_url = f"{d1_web_server.base_url}/admin/logout/" 127 | admin_dashboard_url = f"{d1_web_server.base_url}/admin/" 128 | client = requests.Session() 129 | 130 | # 1. Log in first 131 | csrf_token_login = get_csrf_token(client, login_url) 132 | login_data = { 133 | "username": "admin", 134 | "password": "password", 135 | "csrfmiddlewaretoken": csrf_token_login, 136 | "next": "/admin/", 137 | } 138 | headers_login = {"Referer": login_url} 139 | response_login = client.post(login_url, data=login_data, headers=headers_login, timeout=10) 140 | assert response_login.status_code == 200 141 | assert response_login.url.rstrip('/') == admin_dashboard_url.rstrip('/') 142 | assert "Log out" in response_login.text 143 | 144 | # 2. Logout 145 | # Django's admin logout is a POST request. 146 | # We need a CSRF token. The logout link is usually on an admin page. 147 | # We are already on the admin dashboard (response_login.text). 148 | # We need a CSRF token to POST to the logout URL. 149 | # Django's admin pages typically have the CSRF token available in forms. 150 | # The `get_csrf_token` function can be used on the current page content (admin dashboard). 151 | 152 | # Instead of fetching logout_url via GET (which might log out prematurely or not be a page), 153 | # get the CSRF token from the admin dashboard page content we received after login. 154 | # Note: If the logout button/form is not directly on the dashboard, this might need adjustment. 155 | # However, CSRF token is usually available in hidden input fields on any admin page with forms. 156 | # A more robust way might be to parse response_login.text if get_csrf_token needs a URL. 157 | # For simplicity, let's assume get_csrf_token can work with the client's current cookie state 158 | # and fetching a known admin page (like dashboard itself again, or a specific sub-page known to have a form). 159 | # Re-fetching admin_dashboard_url to get a CSRF token is a safe bet. 160 | 161 | csrf_token_logout = get_csrf_token(client, admin_dashboard_url) 162 | 163 | headers_logout = {"Referer": admin_dashboard_url} # Referer should be the page from which the POST is made 164 | response_logout = client.post(logout_url, data={"csrfmiddlewaretoken": csrf_token_logout}, headers=headers_logout, timeout=10) 165 | 166 | # After a successful POST to /admin/logout/, Django typically redirects to the login page. 167 | # The status code of the POST response itself might be 200 (if it renders a "Logged out" page) 168 | # or 302 (if it immediately redirects). We should check the content of the page it lands on. 169 | # If it's 302, the client will follow it if allow_redirects is True (default for client.post). 170 | 171 | assert response_logout.status_code == 200 # Django's default logout view returns 200 172 | # Check for text that appears on the "Logged out" confirmation page, which is often the login page with a message. 173 | # For Django < 4.0, it's "Logged out". For Django 4.0+, it's "Logged out". Django 5.0 shows "Log in again" on the login page. 174 | assert "Logged out" in response_logout.text or "Log in again" in response_logout.text 175 | 176 | 177 | # 3. Verify user is logged out by trying to access an authenticated page 178 | response_dashboard_after_logout = client.get(admin_dashboard_url, timeout=10, allow_redirects=True) # allow_redirects is True by default for GET too 179 | assert response_dashboard_after_logout.status_code == 200 180 | assert response_dashboard_after_logout.url.startswith(login_url) 181 | assert "Django administration" in response_dashboard_after_logout.text 182 | assert "Log out" not in response_dashboard_after_logout.text 183 | -------------------------------------------------------------------------------- /tests/durable_objects/test_admin.py: -------------------------------------------------------------------------------- 1 | import requests 2 | 3 | from ..utils import durable_objects_web_server # NOQA 4 | 5 | def get_csrf_token(client, url): 6 | """Fetches a page and extracts the CSRF token from a form.""" 7 | try: 8 | response = client.get(url, timeout=10) 9 | response.raise_for_status() 10 | body = response.text 11 | start_str = 'name="csrfmiddlewaretoken" value="' 12 | start_idx = body.find(start_str) 13 | if start_idx == -1: 14 | # Fallback for Django 5.0+ where token might be in a script tag or different structure 15 | # This is a simplified search and might need adjustment 16 | start_str_script = '"csrfToken":"' 17 | start_idx_script = body.find(start_str_script) 18 | if start_idx_script != -1: 19 | start_idx = start_idx_script + len(start_str_script) 20 | end_idx = body.find('"', start_idx) 21 | if end_idx != -1: 22 | return body[start_idx:end_idx] 23 | raise ValueError("CSRF token not found in form or script.") 24 | 25 | start_idx += len(start_str) 26 | end_idx = body.find('"', start_idx) 27 | if end_idx == -1: 28 | raise ValueError("CSRF token not properly formatted in form.") 29 | return body[start_idx:end_idx] 30 | except requests.exceptions.RequestException as e: 31 | print(f"Error fetching CSRF token from {url}: {e}") 32 | raise 33 | except ValueError as e: 34 | print(f"Error parsing CSRF token from {url}: {e}") 35 | raise 36 | 37 | 38 | def test_admin_login_page_loads(durable_objects_web_server): 39 | """Test that the admin login page loads correctly.""" 40 | admin_url = f"{durable_objects_web_server.base_url}/admin/login/" 41 | response = requests.get(admin_url, timeout=10) 42 | assert response.status_code == 200 43 | assert "Django administration" in response.text 44 | 45 | def test_admin_dashboard_unauthorized_access(durable_objects_web_server): 46 | """Test that accessing the admin dashboard without login redirects to the login page.""" 47 | admin_dashboard_url = f"{durable_objects_web_server.base_url}/admin/" 48 | login_url = f"{durable_objects_web_server.base_url}/admin/login/" 49 | 50 | client = requests.Session() 51 | # First check with allow_redirects=False to see the 302 52 | response_no_redirect = client.get(admin_dashboard_url, timeout=10, allow_redirects=False) 53 | assert response_no_redirect.status_code == 302 54 | location_header = response_no_redirect.headers.get("Location", "") 55 | # The location might be relative /admin/login/?next=/admin/ or absolute 56 | assert "admin/login" in location_header 57 | assert "next=/admin/" in location_header 58 | 59 | # Then check with allow_redirects=True (default) to ensure it lands on the login page 60 | response_followed = client.get(admin_dashboard_url, timeout=10) 61 | assert response_followed.status_code == 200 62 | assert response_followed.url.startswith(login_url) # Final URL should be the login page 63 | assert "Django administration" in response_followed.text # Should show the login page content 64 | 65 | def test_admin_login_successful(durable_objects_web_server): 66 | """Test a successful login to the Django admin.""" 67 | login_url = f"{durable_objects_web_server.base_url}/admin/login/" 68 | admin_dashboard_url = f"{durable_objects_web_server.base_url}/admin/" 69 | client = requests.Session() 70 | 71 | csrf_token = get_csrf_token(client, login_url) 72 | login_data = { 73 | "username": "admin", 74 | "password": "password", 75 | "csrfmiddlewaretoken": csrf_token, 76 | "next": "/admin/", 77 | } 78 | headers = {"Referer": login_url} 79 | response = client.post(login_url, data=login_data, headers=headers, timeout=10) 80 | 81 | assert response.status_code == 200 82 | assert response.url.rstrip('/') == admin_dashboard_url.rstrip('/') 83 | assert "Site administration" in response.text 84 | assert "Log out" in response.text 85 | 86 | 87 | def test_admin_login_failed_wrong_password(durable_objects_web_server): 88 | """Test a failed login attempt with a wrong password.""" 89 | login_url = f"{durable_objects_web_server.base_url}/admin/login/" 90 | client = requests.Session() 91 | csrf_token = get_csrf_token(client, login_url) 92 | login_data = { 93 | "username": "admin", 94 | "password": "wrongpassword", 95 | "csrfmiddlewaretoken": csrf_token, 96 | "next": "/admin/", 97 | } 98 | headers = {"Referer": login_url} 99 | response = client.post(login_url, data=login_data, headers=headers, timeout=10) 100 | 101 | assert response.status_code == 200 102 | assert "Please enter the correct username and password" in response.text 103 | assert "Log out" not in response.text 104 | 105 | def test_admin_login_failed_wrong_username(durable_objects_web_server): 106 | """Test a failed login attempt with a non-existent username.""" 107 | login_url = f"{durable_objects_web_server.base_url}/admin/login/" 108 | client = requests.Session() 109 | csrf_token = get_csrf_token(client, login_url) 110 | login_data = { 111 | "username": "nonexistentuser", 112 | "password": "password", 113 | "csrfmiddlewaretoken": csrf_token, 114 | "next": "/admin/", 115 | } 116 | headers = {"Referer": login_url} 117 | response = client.post(login_url, data=login_data, headers=headers, timeout=10) 118 | 119 | assert response.status_code == 200 120 | assert "Please enter the correct username and password" in response.text 121 | assert "Log out" not in response.text 122 | 123 | def test_admin_logout(durable_objects_web_server): 124 | """Test logging out from the Django admin.""" 125 | login_url = f"{durable_objects_web_server.base_url}/admin/login/" 126 | logout_url = f"{durable_objects_web_server.base_url}/admin/logout/" 127 | admin_dashboard_url = f"{durable_objects_web_server.base_url}/admin/" 128 | client = requests.Session() 129 | 130 | # 1. Log in first 131 | csrf_token_login = get_csrf_token(client, login_url) 132 | login_data = { 133 | "username": "admin", 134 | "password": "password", 135 | "csrfmiddlewaretoken": csrf_token_login, 136 | "next": "/admin/", 137 | } 138 | headers_login = {"Referer": login_url} 139 | response_login = client.post(login_url, data=login_data, headers=headers_login, timeout=10) 140 | assert response_login.status_code == 200 141 | assert response_login.url.rstrip('/') == admin_dashboard_url.rstrip('/') 142 | assert "Log out" in response_login.text 143 | 144 | # 2. Logout 145 | # Django's admin logout is a POST request. 146 | # We need a CSRF token. The logout link is usually on an admin page. 147 | # We are already on the admin dashboard (response_login.text). 148 | # We need a CSRF token to POST to the logout URL. 149 | # Django's admin pages typically have the CSRF token available in forms. 150 | # The `get_csrf_token` function can be used on the current page content (admin dashboard). 151 | 152 | # Instead of fetching logout_url via GET (which might log out prematurely or not be a page), 153 | # get the CSRF token from the admin dashboard page content we received after login. 154 | # Note: If the logout button/form is not directly on the dashboard, this might need adjustment. 155 | # However, CSRF token is usually available in hidden input fields on any admin page with forms. 156 | # A more robust way might be to parse response_login.text if get_csrf_token needs a URL. 157 | # For simplicity, let's assume get_csrf_token can work with the client's current cookie state 158 | # and fetching a known admin page (like dashboard itself again, or a specific sub-page known to have a form). 159 | # Re-fetching admin_dashboard_url to get a CSRF token is a safe bet. 160 | 161 | csrf_token_logout = get_csrf_token(client, admin_dashboard_url) 162 | 163 | headers_logout = {"Referer": admin_dashboard_url} # Referer should be the page from which the POST is made 164 | response_logout = client.post(logout_url, data={"csrfmiddlewaretoken": csrf_token_logout}, headers=headers_logout, timeout=10) 165 | 166 | # After a successful POST to /admin/logout/, Django typically redirects to the login page. 167 | # The status code of the POST response itself might be 200 (if it renders a "Logged out" page) 168 | # or 302 (if it immediately redirects). We should check the content of the page it lands on. 169 | # If it's 302, the client will follow it if allow_redirects is True (default for client.post). 170 | 171 | assert response_logout.status_code == 200 # Django's default logout view returns 200 172 | # Check for text that appears on the "Logged out" confirmation page, which is often the login page with a message. 173 | # For Django < 4.0, it's "Logged out". For Django 4.0+, it's "Logged out". Django 5.0 shows "Log in again" on the login page. 174 | assert "Logged out" in response_logout.text or "Log in again" in response_logout.text 175 | 176 | 177 | # 3. Verify user is logged out by trying to access an authenticated page 178 | response_dashboard_after_logout = client.get(admin_dashboard_url, timeout=10, allow_redirects=True) # allow_redirects is True by default for GET too 179 | assert response_dashboard_after_logout.status_code == 200 180 | assert response_dashboard_after_logout.url.startswith(login_url) 181 | assert "Django administration" in response_dashboard_after_logout.text 182 | assert "Log out" not in response_dashboard_after_logout.text 183 | -------------------------------------------------------------------------------- /django_cf/storage/r2.py: -------------------------------------------------------------------------------- 1 | import os 2 | from datetime import datetime 3 | from io import BytesIO 4 | from django.core.files.storage import Storage 5 | from django.core.files.base import File 6 | from django.utils.deconstruct import deconstructible 7 | from js import Uint8Array 8 | 9 | class R2File(File): 10 | """ 11 | A file-like object for R2 storage. 12 | """ 13 | def __init__(self, name, storage, mode='rb'): 14 | self.name = name 15 | self._storage = storage 16 | self._mode = mode 17 | self._file = None 18 | 19 | @property 20 | def file(self): 21 | if self._file is None: 22 | content = self._storage._read(self.name) 23 | self._file = BytesIO(content) if content is not None else BytesIO() 24 | return self._file 25 | 26 | def read(self, num_bytes=None): 27 | return self.file.read(num_bytes) 28 | 29 | def write(self, content): 30 | if 'w' not in self._mode and 'a' not in self._mode: 31 | raise AttributeError("File not opened for writing") 32 | return self.file.write(content) 33 | 34 | def close(self): 35 | if self._file is not None: 36 | self._file.close() 37 | 38 | 39 | @deconstructible 40 | class R2Storage(Storage): 41 | """ 42 | Django storage backend for Cloudflare R2. 43 | 44 | Configuration in Django settings: 45 | STORAGES = { 46 | "default": { 47 | "BACKEND": "django_cf.storage.R2Storage", 48 | "OPTIONS": { 49 | "binding": "BUCKET", # The R2 binding name from wrangler.jsonc 50 | "location": "", # Optional prefix for all files 51 | } 52 | } 53 | } 54 | 55 | In your wrangler.jsonc, configure the R2 bucket binding: 56 | { 57 | "r2_buckets": [ 58 | { 59 | "binding": "BUCKET", 60 | "bucket_name": "" 61 | } 62 | ] 63 | } 64 | """ 65 | 66 | def __init__(self, binding='BUCKET', location='', allow_overwrite=False): 67 | self.binding = binding 68 | self.location = location.strip('/') 69 | self.allow_overwrite = allow_overwrite 70 | self._bucket = None 71 | self._import_from_javascript = None 72 | self._run_sync = None 73 | 74 | def _get_bucket(self): 75 | """Lazy initialization of the R2 bucket binding.""" 76 | if self._bucket is None: 77 | if self._import_from_javascript is None: 78 | try: 79 | from workers import import_from_javascript 80 | from pyodide.ffi import run_sync 81 | self._import_from_javascript = import_from_javascript 82 | self._run_sync = run_sync 83 | 84 | except ImportError as e: 85 | raise Exception("Code not running inside a worker!") 86 | 87 | cf_workers = self._import_from_javascript("cloudflare:workers") 88 | self._bucket = getattr(cf_workers.env, self.binding) 89 | 90 | return self._bucket 91 | 92 | def _full_path(self, name): 93 | """Generate the full path including location prefix.""" 94 | if self.location: 95 | return f"{self.location}/{name}" 96 | return name 97 | 98 | def _open(self, name, mode='rb'): 99 | """ 100 | Retrieve the file from R2. 101 | """ 102 | return R2File(name, self, mode) 103 | 104 | def _read(self, name): 105 | """ 106 | Read the content of a file from R2. 107 | """ 108 | full_path = self._full_path(name) 109 | try: 110 | bucket = self._get_bucket() 111 | r2_object = self._run_sync(bucket.get(full_path)) 112 | 113 | if r2_object is None: 114 | return None 115 | 116 | return self._run_sync(r2_object.arrayBuffer()).to_bytes() 117 | except Exception: 118 | return None 119 | 120 | def _save(self, name, content): 121 | """ 122 | Save a file to R2. 123 | """ 124 | full_path = self._full_path(name) 125 | 126 | if hasattr(content, 'read'): 127 | file_content = content.read() 128 | file_content = Uint8Array.new(file_content) 129 | else: 130 | file_content = content 131 | 132 | bucket = self._get_bucket() 133 | 134 | options = {} 135 | if hasattr(content, 'content_type') and content.content_type: 136 | options['httpMetadata'] = {'contentType': content.content_type} 137 | 138 | self._run_sync(bucket.put(full_path, file_content, options if options else None)) 139 | return name 140 | 141 | def delete(self, name): 142 | """ 143 | Delete a file from R2. 144 | """ 145 | full_path = self._full_path(name) 146 | bucket = self._get_bucket() 147 | self._run_sync(bucket.delete(full_path)) 148 | 149 | def exists(self, name): 150 | """ 151 | Check if a file exists in R2. 152 | """ 153 | full_path = self._full_path(name) 154 | try: 155 | bucket = self._get_bucket() 156 | result = self._run_sync(bucket.head(full_path)).to_py() 157 | return result is not None 158 | except Exception: 159 | return False 160 | 161 | def listdir(self, path): 162 | """ 163 | List the contents of a directory in R2. 164 | 165 | Returns: 166 | tuple: (directories, files) 167 | """ 168 | full_path = self._full_path(path) 169 | if full_path and not full_path.endswith('/'): 170 | full_path += '/' 171 | 172 | bucket = self._get_bucket() 173 | result = self._run_sync(bucket.list({'prefix': full_path, 'delimiter': '/'})).to_py() 174 | 175 | directories = [] 176 | files = [] 177 | 178 | delimited_prefixes = result.get('delimitedPrefixes', []) 179 | for delimited_prefix in delimited_prefixes: 180 | directories.append(os.path.basename(delimited_prefix.replace(full_path, "", 1).rstrip('/'))) 181 | 182 | 183 | objects = result.get('objects', []) 184 | for obj in objects: 185 | _obj = obj.to_py() 186 | 187 | if not obj.key.endswith('/'): 188 | files.append(os.path.basename(obj.key)) 189 | 190 | return directories, files 191 | 192 | def size(self, name): 193 | """ 194 | Return the size of a file in bytes. 195 | """ 196 | full_path = self._full_path(name) 197 | try: 198 | bucket = self._get_bucket() 199 | metadata = self._run_sync(bucket.head(full_path)).to_py() 200 | if metadata and hasattr(metadata, 'size'): 201 | return metadata.size 202 | return 0 203 | except Exception: 204 | return 0 205 | 206 | def url(self, name): 207 | """ 208 | Return the URL for accessing the file. 209 | 210 | Uses Django's MEDIA_URL setting to construct the file URL. 211 | Ensure your web server is configured to serve files from R2 at the MEDIA_URL path. 212 | """ 213 | from django.conf import settings 214 | 215 | if not hasattr(settings, 'MEDIA_URL') or not settings.MEDIA_URL: 216 | raise ValueError( 217 | "MEDIA_URL must be configured in Django settings to use R2Storage. " 218 | "Configure your web server to proxy requests from MEDIA_URL to your R2 bucket." 219 | ) 220 | 221 | full_path = self._full_path(name) 222 | media_url = settings.MEDIA_URL.rstrip('/') 223 | return f"{media_url}/{full_path}" 224 | 225 | def get_accessed_time(self, name): 226 | """ 227 | Return the last accessed time of a file. 228 | R2 doesn't provide access time, so return modified time. 229 | """ 230 | return self.get_modified_time(name) 231 | 232 | def get_created_time(self, name): 233 | """ 234 | Return the creation time of a file. 235 | R2 doesn't provide creation time, so return modified time. 236 | """ 237 | return self.get_modified_time(name) 238 | 239 | def get_modified_time(self, name): 240 | """ 241 | Return the last modified time of a file. 242 | """ 243 | full_path = self._full_path(name) 244 | try: 245 | bucket = self._get_bucket() 246 | metadata = self._run_sync(bucket.head(full_path)).to_py() 247 | 248 | if metadata and hasattr(metadata, 'uploaded'): 249 | uploaded = metadata.uploaded 250 | if isinstance(uploaded, datetime): 251 | return uploaded 252 | return datetime.fromisoformat(str(uploaded)) 253 | 254 | return datetime.now() 255 | except Exception: 256 | return datetime.now() 257 | 258 | def get_available_name(self, name, max_length=None): 259 | """ 260 | Return a filename that's available in R2. 261 | """ 262 | if max_length and len(name) > max_length: 263 | raise Exception(f"File name is too long (max {max_length} characters)") 264 | 265 | if self.allow_overwrite: 266 | return name 267 | 268 | if not self.exists(name): 269 | return name 270 | 271 | dir_name, file_name = os.path.split(name) 272 | file_root, file_ext = os.path.splitext(file_name) 273 | count = 1 274 | 275 | while self.exists(name) and (max_length is None or len(name) <= max_length): 276 | name = os.path.join(dir_name, f"{file_root}_{count}{file_ext}") 277 | count += 1 278 | 279 | return name 280 | -------------------------------------------------------------------------------- /django_cf/db/base_engine.py: -------------------------------------------------------------------------------- 1 | import sqlparse 2 | from django.db import DatabaseError, Error, DataError, OperationalError, \ 3 | IntegrityError, InternalError, ProgrammingError, NotSupportedError, InterfaceError 4 | from django.db.backends.sqlite3.base import DatabaseWrapper as SQLiteDatabaseWrapper 5 | from django.db.backends.sqlite3.client import DatabaseClient as SQLiteDatabaseClient 6 | from django.db.backends.sqlite3.creation import DatabaseCreation as SQLiteDatabaseCreation 7 | from django.db.backends.sqlite3.features import DatabaseFeatures as SQLiteDatabaseFeatures 8 | from django.db.backends.sqlite3.introspection import DatabaseIntrospection as SQLiteDatabaseIntrospection 9 | from django.db.backends.sqlite3.operations import DatabaseOperations as SQLiteDatabaseOperations 10 | from django.db.backends.sqlite3.schema import DatabaseSchemaEditor as SQLiteDatabaseSchemaEditor 11 | 12 | 13 | class CFDatabaseIntrospection(SQLiteDatabaseIntrospection): 14 | pass 15 | 16 | 17 | class CFDatabaseCreation(SQLiteDatabaseCreation): 18 | pass 19 | 20 | 21 | class CFDatabaseClient(SQLiteDatabaseClient): 22 | pass 23 | 24 | 25 | class CFDatabaseSchemaEditor(SQLiteDatabaseSchemaEditor): 26 | def __exit__(self, exc_type, exc_value, traceback): 27 | if exc_type is None: 28 | for sql in self.deferred_sql: 29 | self.execute(sql) 30 | if self.atomic_migration: 31 | self.atomic.__exit__(exc_type, exc_value, traceback) 32 | 33 | 34 | class CFDatabaseOperations(SQLiteDatabaseOperations): 35 | # This patches some weird bugs related to the Database class 36 | def _quote_params_for_last_executed_query(self, params): 37 | """ 38 | Only for last_executed_query! Don't use this to execute SQL queries! 39 | """ 40 | # This function is limited both by SQLITE_LIMIT_VARIABLE_NUMBER (the 41 | # number of parameters, default = 999) and SQLITE_MAX_COLUMN (the 42 | # number of return values, default = 2000). Since Python's sqlite3 43 | # module doesn't expose the get_limit() C API, assume the default 44 | # limits are in effect and split the work in batches if needed. 45 | BATCH_SIZE = 999 46 | if len(params) > BATCH_SIZE: 47 | results = () 48 | for index in range(0, len(params), BATCH_SIZE): 49 | chunk = params[index: index + BATCH_SIZE] 50 | results += self._quote_params_for_last_executed_query(chunk) 51 | return results 52 | 53 | sql = "SELECT " + ", ".join(["QUOTE(?)"] * len(params)) 54 | # Bypass Django's wrappers and use the underlying sqlite3 connection 55 | # to avoid logging this query - it would trigger infinite recursion. 56 | 57 | cursor = self.connection.connection.cursor() 58 | # Native sqlite3 cursors cannot be used as context managers. 59 | # try: 60 | # return cursor.execute(sql, params).fetchone() 61 | # finally: 62 | # cursor.close() 63 | 64 | def last_executed_query(self, cursor, sql, params): 65 | # Python substitutes parameters in Modules/_sqlite/cursor.c with: 66 | # bind_parameters(state, self->statement, parameters); 67 | # Unfortunately there is no way to reach self->statement from Python, 68 | # so we quote and substitute parameters manually. 69 | if params: 70 | if isinstance(params, (list, tuple)): 71 | params = self._quote_params_for_last_executed_query(params) 72 | else: 73 | values = tuple(params.values()) 74 | values = self._quote_params_for_last_executed_query(values) 75 | params = dict(zip(params, values)) 76 | try: 77 | return sql % params 78 | except: 79 | return sql 80 | # For consistency with SQLiteCursorWrapper.execute(), just return sql 81 | # when there are no parameters. See #13648 and #17158. 82 | else: 83 | return sql 84 | 85 | def bulk_insert_sql(self, fields, placeholder_rows): 86 | placeholder_rows_sql = (", ".join(row) for row in placeholder_rows) 87 | values_sql = ", ".join("(%s)" % sql for sql in placeholder_rows_sql) 88 | return "VALUES " + values_sql 89 | 90 | 91 | class CFDatabaseFeatures(SQLiteDatabaseFeatures): 92 | has_select_for_update = True 93 | has_native_uuid_field = False 94 | atomic_transactions = False 95 | supports_transactions = False 96 | can_release_savepoints = False 97 | supports_atomic_references_rename = False 98 | can_clone_databases = False 99 | can_rollback_ddl = False 100 | # Unsupported add column and foreign key in single statement 101 | # https://github.com/pingcap/tidb/issues/45474 102 | can_create_inline_fk = False 103 | order_by_nulls_first = True 104 | create_test_procedure_without_params_sql = None 105 | create_test_procedure_with_int_param_sql = None 106 | supports_aggregate_filter_clause = True 107 | can_defer_constraint_checks = False 108 | supports_pragma_foreign_key_check = False 109 | can_alter_table_rename_column = False 110 | max_query_params = 100 111 | can_clone_databases = False 112 | can_rollback_ddl = False 113 | supports_atomic_references_rename = False 114 | supports_forward_references = False 115 | supports_transactions = False 116 | has_bulk_insert = True 117 | # supports_select_union = False 118 | # supports_select_intersection = False 119 | # supports_select_difference = False 120 | can_return_columns_from_insert = True 121 | 122 | minimum_database_version = (4,) 123 | 124 | 125 | class CFResult: 126 | lastrowid = None 127 | rowcount = -1 128 | 129 | def __init__(self, data): 130 | self.data = data 131 | 132 | def __iter__(self): 133 | return iter(self.data) 134 | 135 | def set_lastrowid(self, value): 136 | self.lastrowid = value 137 | 138 | def set_rowcount(self, value): 139 | self.rowcount = value 140 | 141 | def fetchone(self): 142 | if len(self.data) > 0: 143 | return self.data.pop() 144 | return None 145 | 146 | def fetchall(self): 147 | ret = [] 148 | while True: 149 | row = self.fetchone() 150 | if row is None: 151 | break 152 | ret.append(row) 153 | return ret 154 | 155 | def fetchmany(self, size=1): 156 | ret = [] 157 | while size > 0: 158 | row = self.fetchone() 159 | if row is None: 160 | break 161 | ret.append(row) 162 | if size is not None: 163 | size -= 1 164 | 165 | return ret 166 | 167 | @staticmethod 168 | def from_object(query, params, data, rows_read=None, rows_written=None, last_row_id=None): 169 | try: 170 | from pyodide.ffi import jsnull 171 | except ImportError: 172 | jsnull = None 173 | 174 | result = [] 175 | 176 | for row in data: 177 | row_items = () 178 | if isinstance(row, list): 179 | for v in row: 180 | if v is jsnull: 181 | row_items += (None,) 182 | else: 183 | row_items += (v,) 184 | else: 185 | for k, v in row.items(): 186 | if v is jsnull: 187 | row_items += (None,) 188 | else: 189 | row_items += (v,) 190 | 191 | result.append(row_items) 192 | 193 | instance = CFResult(result) 194 | 195 | if rows_read or rows_written: 196 | if "INSERT" in query.upper(): 197 | instance.set_rowcount(rows_written or 0) 198 | elif "UPDATE" in query.upper() or "DELETE" in query.upper(): 199 | instance.set_rowcount(rows_written or 0) 200 | else: 201 | instance.set_rowcount(rows_read or 0) 202 | 203 | if last_row_id is not None: 204 | instance.set_lastrowid(last_row_id) 205 | 206 | return instance 207 | 208 | 209 | class CFDatabase: 210 | def __init__(self, database_wrapper): 211 | self.databaseWrapper = database_wrapper 212 | 213 | DataError = DataError 214 | 215 | OperationalError = OperationalError 216 | 217 | IntegrityError = IntegrityError 218 | 219 | InternalError = InternalError 220 | 221 | ProgrammingError = ProgrammingError 222 | 223 | NotSupportedError = NotSupportedError 224 | DatabaseError = DatabaseError 225 | InterfaceError = InterfaceError 226 | Error = Error 227 | 228 | _defer_foreign_keys = False 229 | 230 | lastResult: CFResult = None 231 | 232 | def defer_foreign_keys(self, state): 233 | _defer_foreign_keys = state 234 | 235 | @staticmethod 236 | def connect(binding): 237 | return CFDatabase(binding) 238 | 239 | def cursor(self): 240 | return self 241 | 242 | def commit(self): 243 | return # No commits allowed 244 | 245 | def rollback(self): 246 | return # No commits allowed 247 | 248 | def fetchone(self): 249 | return self.lastResult.fetchone() 250 | 251 | def fetchall(self): 252 | return self.lastResult.fetchall() 253 | 254 | def fetchmany(self, size=1): 255 | return self.lastResult.fetchmany(size) 256 | 257 | @property 258 | def lastrowid(self): 259 | return self.lastResult.lastrowid 260 | 261 | @property 262 | def rowcount(self): 263 | return self.lastResult.rowcount 264 | 265 | def execute(self, query, params=None) -> None: 266 | if params: 267 | newParams = [] 268 | for v in list(params): 269 | if v is True: 270 | v = 1 271 | elif v is False: 272 | v = 0 273 | 274 | newParams.append(v) 275 | 276 | params = tuple(newParams) 277 | 278 | self.lastResult = self.databaseWrapper.run_query(query, params) 279 | 280 | return self 281 | 282 | def close(self): 283 | return 284 | 285 | 286 | def is_read_only_query(query: str) -> bool: 287 | parsed = sqlparse.parse(query.strip()) 288 | 289 | if not parsed: 290 | return False # Invalid or empty query 291 | 292 | # Get the first statement 293 | statement = parsed[0] 294 | 295 | # Check if the statement is a SELECT query 296 | if statement.get_type().upper() == "SELECT": 297 | return True 298 | 299 | # List of modifying query types 300 | modifying_types = {"INSERT", "UPDATE", "DELETE", "CREATE", "ALTER", "DROP", "REPLACE"} 301 | 302 | return statement.get_type().upper() not in modifying_types 303 | 304 | 305 | class CFDatabaseWrapper(SQLiteDatabaseWrapper): 306 | # this is defined in the class extending this one 307 | # vendor = "cloudflare_d1" 308 | # display_name = "D1" 309 | 310 | Database = CFDatabase 311 | SchemaEditorClass = CFDatabaseSchemaEditor 312 | client_class = CFDatabaseClient 313 | creation_class = CFDatabaseCreation 314 | 315 | # Classes instantiated in __init__(). 316 | features_class = CFDatabaseFeatures 317 | introspection_class = CFDatabaseIntrospection 318 | ops_class = CFDatabaseOperations 319 | 320 | transaction_modes = frozenset([]) 321 | 322 | def get_database_version(self): 323 | return (4,) 324 | 325 | def get_connection_params(self): 326 | raise NotImplementedError() 327 | 328 | def get_new_connection(self, conn_params): 329 | conn = CFDatabase.connect(self) 330 | return conn 331 | 332 | def create_cursor(self, name=None): 333 | return self.connection.cursor() 334 | 335 | def close(self): 336 | return 337 | 338 | def _savepoint_allowed(self): 339 | return False 340 | 341 | def _set_autocommit(self, commit): 342 | return 343 | 344 | def set_autocommit( 345 | self, autocommit, force_begin_transaction_with_broken_autocommit=False 346 | ): 347 | return 348 | 349 | def disable_constraint_checking(self): 350 | self.cursor().defer_foreign_keys(False) 351 | return True 352 | 353 | def enable_constraint_checking(self): 354 | self.cursor().defer_foreign_keys(True) 355 | 356 | def is_usable(self): 357 | return True 358 | 359 | def run_query(self, query, params=None) -> CFResult: 360 | raise NotImplementedError() 361 | -------------------------------------------------------------------------------- /templates/d1/src/templates/landing.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Django-CF with D1 - Serverless Django 7 | 8 | 65 | 66 | 67 | 68 |
69 | 70 | 71 |
72 |
73 |
74 |
75 |
76 | ⚡ 77 |
78 |
79 |

django-cf

80 |

Django on Cloudflare Workers

81 |
82 |
83 |
84 | ✨ Ready to Deploy 85 |
86 |
87 |
88 |
89 | 90 | 91 |
92 | 93 |
94 |

95 | Django on the Edge 96 |

97 |

98 | A powerful Django template running on Cloudflare Workers with D1 database. Get started by running migrations, creating an admin user, and exploring the included blog example. 99 |

100 |
101 | 102 | 103 |
104 | 105 |
106 |
107 |
108 | 🔧 109 |
110 |
111 |
112 |

Run Migrations

113 |

114 | Initialize your database schema. This creates all necessary tables and relationships in your D1 database. 115 |

116 | 120 |
121 | 122 | 123 |
124 |
125 |
126 | 👤 127 |
128 |
129 |
130 |

Create Admin User

131 |

132 | Create a superuser account for the Django admin interface. 133 |

134 |

135 | Default credentials: admin / password 136 |

137 | 141 |
142 | 143 | 144 |
145 |
146 |
147 | ⚙️ 148 |
149 |
150 |

Admin Panel

151 |

152 | Access Django admin to manage content, users, and configuration. Make sure you've created an admin user first. 153 |

154 | 155 | Open Admin Panel → 156 | 157 |
158 | 159 | 160 |
161 |
162 |
163 | 📝 164 |
165 |
166 |

Blog Example

167 |

168 | Explore a sample blog application to see Django in action. Run migrations first to create the database tables. 169 |

170 | 171 | View Blog → 172 | 173 |
174 |
175 | 176 | 177 |
178 |

Powered by Modern Technology

179 |
180 |
181 |
🐍
182 |

Django

183 |

Full-featured web framework

184 |
185 |
186 |
☁️
187 |

Cloudflare Workers

188 |

Serverless edge computing

189 |
190 |
191 |
💾
192 |

D1 Database

193 |

Serverless SQL database

194 |
195 |
196 |
🚀
197 |

High Performance

198 |

Lightning-fast globally

199 |
200 |
201 |
202 |
203 | 204 | 205 |
206 |
207 |
208 |

Built with ❤️ for serverless Django

209 |
210 | GitHub 211 | D1 Docs 212 | Django Docs 213 |
214 |
215 |
216 |
217 | 218 | 306 | 307 | 308 | -------------------------------------------------------------------------------- /templates/durable-objects/src/templates/landing.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Django-CF with D1 - Serverless Django 7 | 8 | 65 | 66 | 67 | 68 |
69 | 70 | 71 |
72 |
73 |
74 |
75 |
76 | ⚡ 77 |
78 |
79 |

django-cf

80 |

Django on Cloudflare Workers

81 |
82 |
83 |
84 | ✨ Ready to Deploy 85 |
86 |
87 |
88 |
89 | 90 | 91 |
92 | 93 |
94 |

95 | Django on the Edge 96 |

97 |

98 | A powerful Django template running on Cloudflare Workers with D1 database. Get started by running migrations, creating an admin user, and exploring the included blog example. 99 |

100 |
101 | 102 | 103 |
104 | 105 |
106 |
107 |
108 | 🔧 109 |
110 |
111 |
112 |

Run Migrations

113 |

114 | Initialize your database schema. This creates all necessary tables and relationships in your D1 database. 115 |

116 | 120 |
121 | 122 | 123 |
124 |
125 |
126 | 👤 127 |
128 |
129 |
130 |

Create Admin User

131 |

132 | Create a superuser account for the Django admin interface. 133 |

134 |

135 | Default credentials: admin / password 136 |

137 | 141 |
142 | 143 | 144 |
145 |
146 |
147 | ⚙️ 148 |
149 |
150 |

Admin Panel

151 |

152 | Access Django admin to manage content, users, and configuration. Make sure you've created an admin user first. 153 |

154 | 155 | Open Admin Panel → 156 | 157 |
158 | 159 | 160 |
161 |
162 |
163 | 📝 164 |
165 |
166 |

Blog Example

167 |

168 | Explore a sample blog application to see Django in action. Run migrations first to create the database tables. 169 |

170 | 171 | View Blog → 172 | 173 |
174 |
175 | 176 | 177 |
178 |

Powered by Modern Technology

179 |
180 |
181 |
🐍
182 |

Django

183 |

Full-featured web framework

184 |
185 |
186 |
☁️
187 |

Cloudflare Workers

188 |

Serverless edge computing

189 |
190 |
191 |
💾
192 |

D1 Database

193 |

Serverless SQL database

194 |
195 |
196 |
🚀
197 |

High Performance

198 |

Lightning-fast globally

199 |
200 |
201 |
202 |
203 | 204 | 205 |
206 |
207 |
208 |

Built with ❤️ for serverless Django

209 |
210 | GitHub 211 | D1 Docs 212 | Django Docs 213 |
214 |
215 |
216 |
217 | 218 | 306 | 307 | 308 | -------------------------------------------------------------------------------- /django_cf/middleware/CloudflareAccessMiddleware.py: -------------------------------------------------------------------------------- 1 | import json 2 | import base64 3 | import hashlib 4 | import time 5 | import urllib.request 6 | import urllib.error 7 | from django.contrib.auth import get_user_model, login 8 | from django.contrib.auth.models import AnonymousUser 9 | from django.http import JsonResponse 10 | from django.conf import settings 11 | from django.core.cache import cache 12 | import logging 13 | 14 | logger = logging.getLogger(__name__) 15 | 16 | User = get_user_model() 17 | 18 | try: 19 | from js import fetch 20 | from pyodide.ffi import run_sync 21 | 22 | IS_WORKER = True 23 | except ImportError: 24 | IS_WORKER = False 25 | fetch = None 26 | run_sync = None 27 | 28 | 29 | class CloudflareAccessMiddleware: 30 | """ 31 | Django middleware for Cloudflare Access authentication. 32 | 33 | This middleware: 34 | 1. Extracts JWT from CF-Access-Jwt-Assertion header or cf_authorization cookie 35 | 2. Validates the JWT against Cloudflare's public keys 36 | 3. Creates or retrieves user based on JWT claims 37 | 4. Logs the user in automatically 38 | 39 | Settings required (at least one): 40 | - CLOUDFLARE_ACCESS_AUD: Your Cloudflare Access Application Audience (AUD) tag 41 | - CLOUDFLARE_ACCESS_TEAM_NAME: Your Cloudflare team name (e.g., 'yourteam') 42 | 43 | If only AUD is provided, team name will be extracted from JWT claims. 44 | If only team name is provided, AUD will be validated from JWT claims. 45 | """ 46 | 47 | def __init__(self, get_response): 48 | self.get_response = get_response 49 | 50 | # Validate required settings - at least one must be provided 51 | self.aud = getattr(settings, 'CLOUDFLARE_ACCESS_AUD', None) 52 | self.team_name = getattr(settings, 'CLOUDFLARE_ACCESS_TEAM_NAME', None) 53 | 54 | if not self.aud and not self.team_name: 55 | raise ValueError("Either CLOUDFLARE_ACCESS_AUD or CLOUDFLARE_ACCESS_TEAM_NAME setting is required") 56 | 57 | # If team_name is provided, use it to construct the certs URL 58 | if self.team_name: 59 | self.team_domain = f"{self.team_name}.cloudflareaccess.com" 60 | self.certs_url = f"https://{self.team_domain}/cdn-cgi/access/certs" 61 | else: 62 | # We'll determine the team domain from the JWT later 63 | self.team_domain = None 64 | self.certs_url = None 65 | 66 | # Optional settings 67 | self.exempt_paths = getattr(settings, 'CLOUDFLARE_ACCESS_EXEMPT_PATHS', []) 68 | self.cache_timeout = getattr(settings, 'CLOUDFLARE_ACCESS_CACHE_TIMEOUT', 3600) # 1 hour 69 | 70 | def __call__(self, request): 71 | # Always try to authenticate with Cloudflare Access if JWT is present 72 | # This ensures users stay logged in even after Django logout 73 | try: 74 | user = self._authenticate_cloudflare_access(request) 75 | if user: 76 | # Set the user on the request (this overrides any logged-out state) 77 | request.user = user 78 | # Clear any logout-related session data 79 | if hasattr(request, 'session'): 80 | # User was logged out but CF Access is still valid, re-authenticate 81 | try: 82 | login(request, user) 83 | logger.info("Logged in user %s", user) 84 | except Exception as e: 85 | logger.warning(f"Failed to re-login user after logout: {str(e)}") 86 | else: 87 | # Check if path is exempt from authentication 88 | if not self._is_exempt_path(request.path): 89 | # Authentication failed and path is not exempt, return 401 90 | return JsonResponse( 91 | {'error': 'Cloudflare Access authentication required'}, 92 | status=401 93 | ) 94 | # Path is exempt, continue with anonymous user 95 | 96 | except Exception as e: 97 | logger.error(f"Cloudflare Access authentication error: {repr(e)}") 98 | # Only return 500 for non-exempt paths 99 | if not self._is_exempt_path(request.path): 100 | return JsonResponse( 101 | {'error': 'Authentication error'}, 102 | status=500 103 | ) 104 | 105 | return self.get_response(request) 106 | 107 | def _is_exempt_path(self, path): 108 | """Check if the current path is exempt from authentication.""" 109 | for exempt_path in self.exempt_paths: 110 | if path.startswith(exempt_path): 111 | return True 112 | return False 113 | 114 | def _authenticate_cloudflare_access(self, request): 115 | """ 116 | Authenticate user using Cloudflare Access JWT. 117 | Returns User object if valid, None otherwise. 118 | """ 119 | # Extract JWT token from header or cookie 120 | jwt_token = self._extract_jwt_token(request) 121 | if not jwt_token: 122 | return None 123 | 124 | # If we don't have team_name, try to extract it from JWT first 125 | if not self.team_name: 126 | team_name = self._extract_team_name_from_jwt(jwt_token) 127 | if not team_name: 128 | logger.error("Unable to determine team name from JWT") 129 | return None 130 | self.team_domain = f"{team_name}.cloudflareaccess.com" 131 | self.certs_url = f"https://{self.team_domain}/cdn-cgi/access/certs" 132 | 133 | # Get Cloudflare public keys 134 | public_keys = self._get_cloudflare_public_keys() 135 | if not public_keys: 136 | logger.error("Failed to retrieve Cloudflare public keys") 137 | return None 138 | 139 | email = None 140 | name = None 141 | 142 | # Validate and decode JWT 143 | try: 144 | # Try each public key until one works 145 | decoded_token = None 146 | for key_data in public_keys: 147 | try: 148 | decoded_token = self._decode_and_verify_jwt(jwt_token, key_data) 149 | if decoded_token: 150 | break 151 | except Exception as e: 152 | logger.debug(f"Key {key_data.get('kid')} failed: {str(e)}") 153 | continue 154 | 155 | if not decoded_token: 156 | logger.warning("JWT token validation failed with all available keys") 157 | return None 158 | 159 | # Validate AUD if configured 160 | if self.aud: 161 | token_aud = decoded_token.get('aud') 162 | if isinstance(token_aud, list): 163 | if self.aud not in token_aud: 164 | logger.warning(f"JWT audience mismatch. Expected: {self.aud}, Got: {token_aud}") 165 | return None 166 | elif token_aud != self.aud: 167 | logger.warning(f"JWT audience mismatch. Expected: {self.aud}, Got: {token_aud}") 168 | return None 169 | 170 | # Validate AUD if not configured but we have a team name 171 | if not self.aud and self.team_name: 172 | token_aud = decoded_token.get('aud') 173 | if not token_aud: 174 | logger.warning("No audience found in JWT token") 175 | return None 176 | 177 | # Extract user information from JWT claims 178 | email = decoded_token.get('email') 179 | name = decoded_token.get('name', '') 180 | 181 | # Try to get name from custom claims if not in standard claims 182 | if not name: 183 | custom_claims = decoded_token.get('custom', {}) 184 | first_name = custom_claims.get('firstName', '') 185 | last_name = custom_claims.get('lastName', '') 186 | if first_name or last_name: 187 | name = f"{first_name} {last_name}".strip() 188 | 189 | if not email: 190 | logger.warning("No email found in JWT token") 191 | return None 192 | 193 | except Exception as e: 194 | logger.warning(f"JWT token validation error: {repr(e)}") 195 | return None 196 | 197 | # Get or create user 198 | user = self._get_or_create_user(email, name) 199 | return user 200 | 201 | def _extract_jwt_token(self, request): 202 | """Extract JWT token from CF-Access-Jwt-Assertion header or cf_authorization cookie.""" 203 | # Try header first (most common) 204 | jwt_token = request.META.get('HTTP_CF_ACCESS_JWT_ASSERTION') 205 | if jwt_token: 206 | return jwt_token 207 | 208 | # Try alternative header format 209 | jwt_token = request.META.get('HTTP_CF_ACCESS_JWT_ASSERTION'.replace('_', '-')) 210 | if jwt_token: 211 | return jwt_token 212 | 213 | # Try cookie 214 | jwt_token = request.COOKIES.get('CF_Authorization') 215 | if jwt_token: 216 | return jwt_token 217 | 218 | # Try alternative cookie name 219 | jwt_token = request.COOKIES.get('cf_authorization') 220 | if jwt_token: 221 | return jwt_token 222 | 223 | return None 224 | 225 | def _extract_team_name_from_jwt(self, jwt_token): 226 | """Extract team name from JWT token without validation (for bootstrapping).""" 227 | try: 228 | # Split JWT into parts 229 | parts = jwt_token.split('.') 230 | if len(parts) != 3: 231 | return None 232 | 233 | # Decode payload (add padding if needed) 234 | payload_part = parts[1] 235 | payload_part += '=' * (4 - len(payload_part) % 4) 236 | payload_bytes = base64.urlsafe_b64decode(payload_part) 237 | payload = json.loads(payload_bytes.decode('utf-8')) 238 | 239 | # Extract issuer (iss) claim - format: https://teamname.cloudflareaccess.com 240 | issuer = payload.get('iss') 241 | if not issuer: 242 | return None 243 | 244 | # Extract team name from issuer URL 245 | if issuer.startswith('https://') and issuer.endswith('.cloudflareaccess.com'): 246 | team_name = issuer.replace('https://', '').replace('.cloudflareaccess.com', '') 247 | return team_name 248 | 249 | return None 250 | except Exception as e: 251 | logger.error(f"Failed to extract team name from JWT: {str(e)}") 252 | return None 253 | 254 | def _get_cloudflare_public_keys(self): 255 | """Retrieve Cloudflare public keys, with caching.""" 256 | # Use team_name for cache key, or extract from team_domain if available 257 | cache_key_team = self.team_name or (self.team_domain.split('.')[0] if self.team_domain else 'unknown') 258 | cache_key = f"cloudflare_access_keys_{cache_key_team}" 259 | cached_keys = cache.get(cache_key) 260 | 261 | if cached_keys: 262 | return cached_keys 263 | 264 | if IS_WORKER: 265 | response = run_sync(fetch(self.certs_url)) 266 | if response.status == 200: 267 | data = run_sync(response.json()).to_py() 268 | else: 269 | logger.error(f"Failed to fetch Cloudflare keys: HTTP {response.status}") 270 | return None 271 | else: 272 | try: 273 | with urllib.request.urlopen(self.certs_url) as response: 274 | if response.status == 200: 275 | data = json.loads(response.read().decode('utf-8')) 276 | else: 277 | logger.error(f"Failed to fetch Cloudflare keys: HTTP {response.status}") 278 | return None 279 | except urllib.error.URLError as e: 280 | logger.error(f"Network error fetching Cloudflare keys: {str(e)}") 281 | return None 282 | except Exception as e: 283 | logger.error(f"Unexpected error fetching Cloudflare keys: {str(e)}") 284 | return None 285 | 286 | keys = data.get('keys', []) 287 | 288 | # Process keys for JWT validation 289 | processed_keys = [] 290 | for key_info in keys: 291 | if key_info.get('kty') == 'RSA': 292 | # Extract RSA components 293 | try: 294 | processed_key = self._process_rsa_key(key_info) 295 | if processed_key: 296 | processed_keys.append(processed_key) 297 | except Exception as e: 298 | logger.warning(f"Failed to process key {key_info.get('kid')}: {str(e)}") 299 | continue 300 | 301 | # Cache the keys 302 | cache.set(cache_key, processed_keys, self.cache_timeout) 303 | return processed_keys 304 | 305 | def _process_rsa_key(self, key_info): 306 | """Process RSA key from JWK format to usable format.""" 307 | try: 308 | # Extract RSA components from JWK 309 | n = key_info.get('n') # modulus 310 | e = key_info.get('e') # exponent 311 | kid = key_info.get('kid') 312 | 313 | if not n or not e: 314 | return None 315 | 316 | # Decode base64url encoded values 317 | n_bytes = self._base64url_decode(n) 318 | e_bytes = self._base64url_decode(e) 319 | 320 | return { 321 | 'kid': kid, 322 | 'n': int.from_bytes(n_bytes, 'big'), 323 | 'e': int.from_bytes(e_bytes, 'big') 324 | } 325 | except Exception as e: 326 | logger.warning(f"Failed to process RSA key: {str(e)}") 327 | return None 328 | 329 | def _base64url_decode(self, data): 330 | """Decode base64url encoded data.""" 331 | # Add padding if needed 332 | data += '=' * (4 - len(data) % 4) 333 | return base64.urlsafe_b64decode(data) 334 | 335 | def _decode_and_verify_jwt(self, jwt_token, key_data): 336 | """Decode and verify JWT token using RSA key.""" 337 | try: 338 | # Split JWT into parts 339 | parts = jwt_token.split('.') 340 | if len(parts) != 3: 341 | raise ValueError("Invalid JWT format") 342 | 343 | header_part, payload_part, signature_part = parts 344 | 345 | # Decode header 346 | header_bytes = self._base64url_decode(header_part) 347 | header = json.loads(header_bytes.decode('utf-8')) 348 | 349 | # Check if this key matches the kid in the header 350 | if header.get('kid') != key_data.get('kid'): 351 | raise ValueError("Key ID mismatch") 352 | 353 | # Check algorithm 354 | if header.get('alg') != 'RS256': 355 | raise ValueError("Unsupported algorithm") 356 | 357 | # Decode payload 358 | payload_bytes = self._base64url_decode(payload_part) 359 | payload = json.loads(payload_bytes.decode('utf-8')) 360 | 361 | # Check expiration 362 | exp = payload.get('exp') 363 | if exp and exp < time.time(): 364 | raise ValueError("Token expired") 365 | 366 | # Check not before 367 | nbf = payload.get('nbf') 368 | if nbf and nbf > time.time(): 369 | raise ValueError("Token not yet valid") 370 | 371 | # Verify signature 372 | message = f"{header_part}.{payload_part}".encode('utf-8') 373 | signature = self._base64url_decode(signature_part) 374 | 375 | if not self._verify_rsa_signature(message, signature, key_data): 376 | raise ValueError("Invalid signature") 377 | 378 | return payload 379 | 380 | except Exception as e: 381 | logger.debug(f"JWT verification failed: {str(e)}") 382 | return None 383 | 384 | def _verify_rsa_signature(self, message, signature, key_data): 385 | """Verify RSA signature using PKCS#1 v1.5 with SHA-256.""" 386 | try: 387 | # Hash the message 388 | hash_obj = hashlib.sha256() 389 | hash_obj.update(message) 390 | message_hash = hash_obj.digest() 391 | 392 | # Create PKCS#1 v1.5 padding for SHA-256 393 | # DigestInfo for SHA-256: 30 31 30 0d 06 09 60 86 48 01 65 03 04 02 01 05 00 04 20 394 | digest_info = bytes.fromhex('3031300d060960864801650304020105000420') 395 | padded_hash = digest_info + message_hash 396 | 397 | # RSA signature verification 398 | n = key_data['n'] 399 | e = key_data['e'] 400 | 401 | # Convert signature to integer 402 | sig_int = int.from_bytes(signature, 'big') 403 | 404 | # RSA verification: sig^e mod n 405 | decrypted_int = pow(sig_int, e, n) 406 | 407 | # Convert back to bytes with proper padding 408 | # The key length in bytes 409 | key_length = (n.bit_length() + 7) // 8 410 | 411 | # Ensure we have the right number of bytes 412 | decrypted_bytes = decrypted_int.to_bytes(key_length, 'big') 413 | 414 | # Check minimum length 415 | if len(decrypted_bytes) < len(padded_hash) + 11: 416 | logger.debug(f"Decrypted bytes too short: {len(decrypted_bytes)} < {len(padded_hash) + 11}") 417 | return False 418 | 419 | # PKCS#1 v1.5 padding format: 0x00 0x01 [0xFF padding] 0x00 [DigestInfo + Hash] 420 | if len(decrypted_bytes) == 0 or decrypted_bytes[0] != 0x00: 421 | logger.debug(f"Invalid padding: first byte is {decrypted_bytes[0]:02x}, expected 0x00") 422 | return False 423 | 424 | if len(decrypted_bytes) < 2 or decrypted_bytes[1] != 0x01: 425 | logger.debug(f"Invalid padding: second byte is {decrypted_bytes[1]:02x}, expected 0x01") 426 | return False 427 | 428 | # Find the 0x00 separator 429 | separator_idx = -1 430 | for i in range(2, len(decrypted_bytes)): 431 | if decrypted_bytes[i] == 0x00: 432 | separator_idx = i 433 | break 434 | elif decrypted_bytes[i] != 0xFF: 435 | logger.debug(f"Invalid padding byte at position {i}: {decrypted_bytes[i]:02x}, expected 0xFF") 436 | return False 437 | 438 | if separator_idx == -1: 439 | logger.debug("No separator found in padding") 440 | return False 441 | 442 | # Ensure minimum padding length (at least 8 bytes of 0xFF) 443 | if separator_idx - 2 < 8: 444 | logger.debug(f"Padding too short: {separator_idx - 2} < 8") 445 | return False 446 | 447 | # Extract and compare the hash 448 | extracted_hash = decrypted_bytes[separator_idx + 1:] 449 | 450 | if len(extracted_hash) != len(padded_hash): 451 | logger.debug(f"Hash length mismatch: {len(extracted_hash)} != {len(padded_hash)}") 452 | return False 453 | 454 | result = extracted_hash == padded_hash 455 | if not result: 456 | logger.debug("Hash comparison failed") 457 | logger.debug(f"Expected: {padded_hash.hex()}") 458 | logger.debug(f"Got: {extracted_hash.hex()}") 459 | 460 | return result 461 | 462 | except Exception as e: 463 | logger.debug(f"RSA signature verification failed: {str(e)}") 464 | return False 465 | 466 | def _get_or_create_user(self, email, name): 467 | """Get or create Django user from Cloudflare Access claims.""" 468 | try: 469 | # Try to get existing user by email 470 | user = User.objects.get(email=email) 471 | 472 | # Update name if it has changed 473 | if name and user.get_full_name() != name: 474 | # Split name into first and last name 475 | name_parts = name.split(' ', 1) 476 | user.first_name = name_parts[0] 477 | user.last_name = name_parts[1] if len(name_parts) > 1 else '' 478 | user.save() 479 | 480 | return user 481 | 482 | except User.DoesNotExist: 483 | # Create new user 484 | name_parts = name.split(' ', 1) if name else ['', ''] 485 | 486 | user = User.objects.create_user( 487 | username=email, # Use email as username 488 | email=email, 489 | first_name=name_parts[0], 490 | last_name=name_parts[1] if len(name_parts) > 1 else '', 491 | is_active=True 492 | ) 493 | 494 | logger.info(f"Created new user from Cloudflare Access: {email}") 495 | return user 496 | --------------------------------------------------------------------------------