From 7435020859c48a6db3dc6e02ac7e9c78c0643086 Mon Sep 17 00:00:00 2001
From: Gyeongjae Choi
Date: Tue, 1 Sep 2026 22:25:33 +0900
Subject: [PATCH 1/5] wip
---
README.md | 1 +
django-markdown-r2/README.md | 58 +++++++++
django-markdown-r2/package.json | 13 ++
django-markdown-r2/pyproject.toml | 17 +++
django-markdown-r2/src/articles/__init__.py | 0
django-markdown-r2/src/articles/apps.py | 6 +
django-markdown-r2/src/articles/forms.py | 32 +++++
django-markdown-r2/src/articles/models.py | 26 ++++
.../templates/articles/article_detail.html | 23 ++++
.../templates/articles/article_form.html | 28 +++++
.../templates/articles/article_list.html | 22 ++++
.../src/articles/templates/articles/base.html | 25 ++++
django-markdown-r2/src/articles/views.py | 79 ++++++++++++
django-markdown-r2/src/entry.py | 38 ++++++
.../src/markdown_project/__init__.py | 0
.../src/markdown_project/db/__init__.py | 0
.../markdown_project/db/backends/__init__.py | 0
.../db/backends/do/__init__.py | 0
.../markdown_project/db/backends/do/base.py | 24 ++++
.../src/markdown_project/settings.py | 40 ++++++
.../src/markdown_project/urls.py | 10 ++
.../src/markdown_project/wsgi.py | 7 ++
django-markdown-r2/wrangler.jsonc | 32 +++++
tests/test_examples.py | 115 ++++++++++++++++++
24 files changed, 596 insertions(+)
create mode 100644 django-markdown-r2/README.md
create mode 100644 django-markdown-r2/package.json
create mode 100644 django-markdown-r2/pyproject.toml
create mode 100644 django-markdown-r2/src/articles/__init__.py
create mode 100644 django-markdown-r2/src/articles/apps.py
create mode 100644 django-markdown-r2/src/articles/forms.py
create mode 100644 django-markdown-r2/src/articles/models.py
create mode 100644 django-markdown-r2/src/articles/templates/articles/article_detail.html
create mode 100644 django-markdown-r2/src/articles/templates/articles/article_form.html
create mode 100644 django-markdown-r2/src/articles/templates/articles/article_list.html
create mode 100644 django-markdown-r2/src/articles/templates/articles/base.html
create mode 100644 django-markdown-r2/src/articles/views.py
create mode 100644 django-markdown-r2/src/entry.py
create mode 100644 django-markdown-r2/src/markdown_project/__init__.py
create mode 100644 django-markdown-r2/src/markdown_project/db/__init__.py
create mode 100644 django-markdown-r2/src/markdown_project/db/backends/__init__.py
create mode 100644 django-markdown-r2/src/markdown_project/db/backends/do/__init__.py
create mode 100644 django-markdown-r2/src/markdown_project/db/backends/do/base.py
create mode 100644 django-markdown-r2/src/markdown_project/settings.py
create mode 100644 django-markdown-r2/src/markdown_project/urls.py
create mode 100644 django-markdown-r2/src/markdown_project/wsgi.py
create mode 100644 django-markdown-r2/wrangler.jsonc
diff --git a/README.md b/README.md
index be42e42..8793269 100644
--- a/README.md
+++ b/README.md
@@ -34,6 +34,7 @@ Need to deploy your Worker to Cloudflare? Python Workers are in open beta and ha
- [**`dynamic-py-py/`**](dynamic-py-py) — shows how to load and run a Python Worker dynamically at runtime using a [Worker Loader](https://developers.cloudflare.com/workers/runtime-apis/bindings/worker-loader/) binding.
- [**`django/`**](django) — runs a naive Django WSGI application directly on Python Workers.
- [**`django-todo-d1/`**](django-todo-d1) — implements the Todo-Backend API with Django and D1.
+- [**`django-markdown-r2/`**](django-markdown-r2) — a server-rendered Django knowledge base using Durable Object SQLite for Markdown articles and R2 for optional images.
- [**`image-redraw/`**](image-redraw) — an example that combines [FastAPI](https://fastapi.tiangolo.com/), [R2](https://developers.cloudflare.com/r2/), [Queues](https://developers.cloudflare.com/queues/), [Workflows](https://developers.cloudflare.com/workflows/) and [Workers AI](https://developers.cloudflare.com/workers-ai/) to redraw uploaded images.
diff --git a/django-markdown-r2/README.md b/django-markdown-r2/README.md
new file mode 100644
index 0000000..624832b
--- /dev/null
+++ b/django-markdown-r2/README.md
@@ -0,0 +1,58 @@
+# Django Markdown Knowledge Base + Durable Objects + R2
+
+[](https://deploy.workers.cloudflare.com/?url=https://github.com/cloudflare/python-workers-examples/tree/main/django-markdown-r2)
+
+A server-rendered knowledge base built with Django on Cloudflare Python Workers. Django templates and forms render every HTML page; the example has no JavaScript or custom/local CSS. It uses the pinned Pico CSS 2.1.1 stylesheet from jsDelivr for its semantic UI, with a usable unstyled HTML fallback if the CDN is unavailable. Article records and Markdown live in a Durable Object SQLite database, while optional article images live in R2.
+
+## Architecture
+
+- `KnowledgeBase` extends [DjangoCFDurableObject](https://pypi.org/project/django-cf/) and `DurableObject`; the Worker forwards every request to the fixed `knowledge-base` instance.
+- A small backend shim extends `django_cf.db.backends.do` so current and older DO SQL result types both work with Django's ORM.
+- The Durable Object constructor creates the `articles` table and its index with idempotent SQL.
+- `django_cf.storage.R2Storage` uses the `IMAGES` R2 binding with an `images/` key prefix.
+- The Worker serves private R2 objects through `GET /media/images/`; images do not need a public R2 bucket URL.
+- Article Markdown is rendered only with `MarkdownIt("js-default")`, which disables raw HTML and unsafe URL schemes.
+
+## Local setup
+
+Install [uv](https://docs.astral.sh/uv/getting-started/installation/#standalone-installer), then install the project dependencies and start the Worker:
+
+```sh
+uv sync
+uv run pywrangler dev
+```
+
+Open http://localhost:8787/. Wrangler provisions the configured Durable Object locally and simulates the `IMAGES` R2 binding.
+
+## Remote setup and deployment
+
+Create an R2 bucket, update the `IMAGES` bucket name in `wrangler.jsonc`, and deploy:
+
+```sh
+uv run pywrangler deploy
+```
+
+The Durable Object is provisioned by the migration configuration on deploy. The R2 bucket needs no public access setting because Django serves images itself.
+
+## Routes
+
+| Route | Purpose |
+|---|---|
+| `GET /` | List articles |
+| `GET`, `POST /articles/new/` | Create an article and optionally upload an image |
+| `GET /articles//` | Render one article |
+| `GET`, `POST /articles//edit/` | Edit title, slug, and Markdown while retaining the existing image |
+| `GET /media/images/` | Serve an R2-backed article image |
+
+## Limits and security
+
+- Titles are limited to 200 characters, slugs to 100 characters, and Markdown to 20,000 characters.
+- Image filenames must use a PNG, JPEG, GIF, or WebP extension.
+- R2 object names are generated by Django and `allow_overwrite=False` prevents uploads from replacing an existing object.
+- The media route validates paths, only sends allowlisted image content types, sets `Content-Disposition: inline` and `X-Content-Type-Options: nosniff`, and returns 404 for absent files.
+- `R2Storage` buffers an object while reading it, so this example is intended for small images.
+- Forms use Django CSRF middleware and `{% csrf_token %}`. Duplicate slugs are rejected during form validation.
+
+## Current limitations
+
+Python Workers and `django-cf` are currently alpha/open-beta software. The compatibility shim is specific to the pinned `django-cf==0.2.15` release and can be removed when its Durable Object backend accepts Python list results directly. One fixed `knowledge-base` Durable Object provides strong consistency and serializes all database traffic, so this pattern is intended for a small knowledge base rather than high-throughput or globally sharded data. Its schema is created by idempotent SQL in the Durable Object constructor; there is no standard Django migration command. The example does not use Django admin, authentication, sessions, deletion, or image replacement/cleanup. Pyodide does not provide Django's timezone data here, so timestamps are stored as naive UTC values (`USE_TZ = False`).
diff --git a/django-markdown-r2/package.json b/django-markdown-r2/package.json
new file mode 100644
index 0000000..0b530ce
--- /dev/null
+++ b/django-markdown-r2/package.json
@@ -0,0 +1,13 @@
+{
+ "name": "django-markdown-r2-worker",
+ "version": "0.0.0",
+ "private": true,
+ "scripts": {
+ "deploy": "uv run pywrangler deploy",
+ "dev": "uv run pywrangler dev",
+ "start": "uv run pywrangler dev"
+ },
+ "devDependencies": {
+ "wrangler": "^4.114.0"
+ }
+}
diff --git a/django-markdown-r2/pyproject.toml b/django-markdown-r2/pyproject.toml
new file mode 100644
index 0000000..0ebe3a9
--- /dev/null
+++ b/django-markdown-r2/pyproject.toml
@@ -0,0 +1,17 @@
+[project]
+name = "django-markdown-r2-worker"
+version = "0.1.0"
+description = "Server-rendered Django knowledge base backed by Durable Object SQLite and R2"
+readme = "README.md"
+requires-python = ">=3.13"
+dependencies = [
+ "django",
+ "django-cf==0.2.15",
+ "markdown-it-py==4.2.0",
+]
+
+[dependency-groups]
+dev = [
+ "workers-py",
+ "workers-runtime-sdk",
+]
diff --git a/django-markdown-r2/src/articles/__init__.py b/django-markdown-r2/src/articles/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/django-markdown-r2/src/articles/apps.py b/django-markdown-r2/src/articles/apps.py
new file mode 100644
index 0000000..9baf7c9
--- /dev/null
+++ b/django-markdown-r2/src/articles/apps.py
@@ -0,0 +1,6 @@
+from django.apps import AppConfig
+
+
+class ArticlesConfig(AppConfig):
+ default_auto_field = "django.db.models.BigAutoField"
+ name = "articles"
diff --git a/django-markdown-r2/src/articles/forms.py b/django-markdown-r2/src/articles/forms.py
new file mode 100644
index 0000000..835d00f
--- /dev/null
+++ b/django-markdown-r2/src/articles/forms.py
@@ -0,0 +1,32 @@
+from django.core.exceptions import ValidationError
+
+from django import forms
+
+from .models import Article
+
+
+class ArticleForm(forms.ModelForm):
+ class Meta:
+ model = Article
+ fields = ["title", "slug", "body", "image"]
+ widgets = {
+ "body": forms.Textarea(attrs={"rows": 16}),
+ "image": forms.ClearableFileInput(
+ attrs={"accept": "image/gif,image/jpeg,image/png,image/webp"}
+ ),
+ }
+ help_texts = {"image": "PNG, JPEG, GIF, or WebP."}
+
+ def clean_slug(self):
+ slug = self.cleaned_data["slug"]
+ matches = Article.objects.filter(slug=slug)
+ if self.instance.pk:
+ matches = matches.exclude(pk=self.instance.pk)
+ if matches.exists():
+ raise ValidationError("An article with this slug already exists.")
+ return slug
+
+
+class ArticleEditForm(ArticleForm):
+ class Meta(ArticleForm.Meta):
+ fields = ["title", "slug", "body"]
diff --git a/django-markdown-r2/src/articles/models.py b/django-markdown-r2/src/articles/models.py
new file mode 100644
index 0000000..fd1f6f9
--- /dev/null
+++ b/django-markdown-r2/src/articles/models.py
@@ -0,0 +1,26 @@
+import uuid
+
+from django.core.validators import FileExtensionValidator
+from django.db import models
+
+
+def generate_article_id():
+ return str(uuid.uuid4())
+
+
+class Article(models.Model):
+ id = models.CharField(primary_key=True, max_length=36, default=generate_article_id)
+ title = models.CharField(max_length=200)
+ slug = models.SlugField(max_length=100, unique=True)
+ body = models.TextField(max_length=20_000)
+ image = models.FileField(
+ upload_to="articles",
+ blank=True,
+ validators=[FileExtensionValidator(["gif", "jpeg", "jpg", "png", "webp"])],
+ )
+ created_at = models.DateTimeField(auto_now_add=True)
+ updated_at = models.DateTimeField(auto_now=True)
+
+ class Meta:
+ db_table = "articles"
+ ordering = ["-created_at"]
diff --git a/django-markdown-r2/src/articles/templates/articles/article_detail.html b/django-markdown-r2/src/articles/templates/articles/article_detail.html
new file mode 100644
index 0000000..b03c61d
--- /dev/null
+++ b/django-markdown-r2/src/articles/templates/articles/article_detail.html
@@ -0,0 +1,23 @@
+{% extends "articles/base.html" %}
+
+{% block title %}{{ article.title }} | Knowledge base{% endblock %}
+
+{% block content %}
+
+
+ {{ article.title }}
+
+
+ {% if article.image %}
+
+
+
+ {% endif %}
+ {{ rendered_body }}
+
+{% endblock %}
diff --git a/django-markdown-r2/src/articles/templates/articles/article_form.html b/django-markdown-r2/src/articles/templates/articles/article_form.html
new file mode 100644
index 0000000..9b8883b
--- /dev/null
+++ b/django-markdown-r2/src/articles/templates/articles/article_form.html
@@ -0,0 +1,28 @@
+{% extends "articles/base.html" %}
+
+{% block title %}{% if is_edit %}Edit {{ article.title }}{% else %}New article{% endif %} | Knowledge base{% endblock %}
+
+{% block content %}
+
+ {% if is_edit %}Edit article{% else %}New article{% endif %}
+
+
+{% endblock %}
diff --git a/django-markdown-r2/src/articles/templates/articles/article_list.html b/django-markdown-r2/src/articles/templates/articles/article_list.html
new file mode 100644
index 0000000..9cf8232
--- /dev/null
+++ b/django-markdown-r2/src/articles/templates/articles/article_list.html
@@ -0,0 +1,22 @@
+{% extends "articles/base.html" %}
+
+{% block title %}Articles | Knowledge base{% endblock %}
+
+{% block content %}
+
+ Articles
+ {% if articles %}
+ {% for article in articles %}
+
+
+ {{ article.body|truncatechars:180 }}
+
+ {% endfor %}
+ {% else %}
+ No articles have been published yet.
+ Create your first article
+ {% endif %}
+
+{% endblock %}
diff --git a/django-markdown-r2/src/articles/templates/articles/base.html b/django-markdown-r2/src/articles/templates/articles/base.html
new file mode 100644
index 0000000..ff876f5
--- /dev/null
+++ b/django-markdown-r2/src/articles/templates/articles/base.html
@@ -0,0 +1,25 @@
+
+
+
+
+
+ {% block title %}Knowledge base{% endblock %}
+
+
+
+
+
+ {% block content %}{% endblock %}
+
+
+
diff --git a/django-markdown-r2/src/articles/views.py b/django-markdown-r2/src/articles/views.py
new file mode 100644
index 0000000..0b3817f
--- /dev/null
+++ b/django-markdown-r2/src/articles/views.py
@@ -0,0 +1,79 @@
+from pathlib import PurePosixPath
+
+from django.core.files.storage import default_storage
+from django.http import Http404, HttpResponse
+from django.shortcuts import get_object_or_404, redirect, render
+from django.utils.safestring import mark_safe
+from markdown_it import MarkdownIt
+
+from .forms import ArticleEditForm, ArticleForm
+from .models import Article
+
+IMAGE_CONTENT_TYPES = {
+ ".gif": "image/gif",
+ ".jpeg": "image/jpeg",
+ ".jpg": "image/jpeg",
+ ".png": "image/png",
+ ".webp": "image/webp",
+}
+
+
+def render_markdown(markdown):
+ return mark_safe(MarkdownIt("js-default").render(markdown))
+
+
+def article_list(request):
+ return render(
+ request, "articles/article_list.html", {"articles": Article.objects.all()}
+ )
+
+
+def article_detail(request, slug):
+ article = get_object_or_404(Article, slug=slug)
+ return render(
+ request,
+ "articles/article_detail.html",
+ {"article": article, "rendered_body": render_markdown(article.body)},
+ )
+
+
+def article_create(request):
+ form = ArticleForm(request.POST or None, request.FILES or None)
+ if request.method == "POST" and form.is_valid():
+ article = form.save()
+ return redirect("article-detail", slug=article.slug)
+ return render(
+ request, "articles/article_form.html", {"form": form, "is_edit": False}
+ )
+
+
+def article_edit(request, slug):
+ article = get_object_or_404(Article, slug=slug)
+ form = ArticleEditForm(request.POST or None, instance=article)
+ if request.method == "POST" and form.is_valid():
+ article = form.save()
+ return redirect("article-detail", slug=article.slug)
+ return render(
+ request,
+ "articles/article_form.html",
+ {"article": article, "form": form, "is_edit": True},
+ )
+
+
+def media_image(request, name):
+ path = PurePosixPath(name)
+ if (
+ not name
+ or "\\" in name
+ or path.is_absolute()
+ or any(part in {"", ".", ".."} for part in path.parts)
+ ):
+ raise Http404
+ content_type = IMAGE_CONTENT_TYPES.get(path.suffix.lower())
+ if content_type is None or not default_storage.exists(name):
+ raise Http404
+ with default_storage.open(name, "rb") as image_file:
+ response = HttpResponse(image_file.read(), content_type=content_type)
+ response["Content-Disposition"] = "inline"
+ response["X-Content-Type-Options"] = "nosniff"
+ return response
diff --git a/django-markdown-r2/src/entry.py b/django-markdown-r2/src/entry.py
new file mode 100644
index 0000000..3b90303
--- /dev/null
+++ b/django-markdown-r2/src/entry.py
@@ -0,0 +1,38 @@
+import os
+
+from django_cf import DjangoCFDurableObject
+from workers import DurableObject, WorkerEntrypoint
+
+os.environ.setdefault("DJANGO_SETTINGS_MODULE", "markdown_project.settings")
+from markdown_project.wsgi import application
+
+
+class KnowledgeBase(DjangoCFDurableObject, DurableObject):
+ def __init__(self, ctx, env):
+ super().__init__(ctx, env)
+ self.ctx.storage.sql.exec(
+ """
+ CREATE TABLE IF NOT EXISTS articles (
+ id TEXT PRIMARY KEY,
+ title VARCHAR(200) NOT NULL,
+ slug VARCHAR(100) NOT NULL UNIQUE,
+ body TEXT NOT NULL,
+ image VARCHAR(100) NOT NULL DEFAULT '',
+ created_at DATETIME NOT NULL,
+ updated_at DATETIME NOT NULL
+ )
+ """
+ )
+ self.ctx.storage.sql.exec(
+ "CREATE INDEX IF NOT EXISTS articles_created_at_idx ON articles (created_at DESC)"
+ )
+
+ def get_app(self):
+ return application
+
+
+class Default(WorkerEntrypoint):
+ async def fetch(self, request):
+ id = self.env.DO_STORAGE.idFromName("knowledge-base")
+ stub = self.env.DO_STORAGE.get(id)
+ return await stub.fetch(request)
diff --git a/django-markdown-r2/src/markdown_project/__init__.py b/django-markdown-r2/src/markdown_project/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/django-markdown-r2/src/markdown_project/db/__init__.py b/django-markdown-r2/src/markdown_project/db/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/django-markdown-r2/src/markdown_project/db/backends/__init__.py b/django-markdown-r2/src/markdown_project/db/backends/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/django-markdown-r2/src/markdown_project/db/backends/do/__init__.py b/django-markdown-r2/src/markdown_project/db/backends/do/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/django-markdown-r2/src/markdown_project/db/backends/do/base.py b/django-markdown-r2/src/markdown_project/db/backends/do/base.py
new file mode 100644
index 0000000..ca2ad1d
--- /dev/null
+++ b/django-markdown-r2/src/markdown_project/db/backends/do/base.py
@@ -0,0 +1,24 @@
+from django_cf.db.backends.do.base import DatabaseWrapper as DjangoCFDatabaseWrapper
+from django_cf.db.backends.do.storage import get_storage
+from django_cf.db.base_engine import CFResult
+
+
+class DatabaseWrapper(DjangoCFDatabaseWrapper):
+ def run_query(self, query, params=None) -> CFResult:
+ processed_query, params = self.process_query(query, params)
+ db = get_storage()
+ statement = (
+ db.exec(processed_query, *params) if params else db.exec(processed_query)
+ )
+
+ rows = statement.raw().toArray()
+ if not isinstance(rows, list):
+ rows = rows.to_py()
+
+ return CFResult.from_object(
+ query,
+ params,
+ rows,
+ statement.rowsRead,
+ statement.rowsWritten,
+ )
diff --git a/django-markdown-r2/src/markdown_project/settings.py b/django-markdown-r2/src/markdown_project/settings.py
new file mode 100644
index 0000000..4a0662c
--- /dev/null
+++ b/django-markdown-r2/src/markdown_project/settings.py
@@ -0,0 +1,40 @@
+SECRET_KEY = "django-insecure-development-placeholder"
+DEBUG = False
+ALLOWED_HOSTS = ["*"]
+ROOT_URLCONF = "markdown_project.urls"
+WSGI_APPLICATION = "markdown_project.wsgi.application"
+DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
+INSTALLED_APPS = ["articles"]
+MIDDLEWARE = [
+ "django.middleware.common.CommonMiddleware",
+ "django.middleware.csrf.CsrfViewMiddleware",
+]
+DATABASES = {
+ "default": {
+ "ENGINE": "markdown_project.db.backends.do",
+ }
+}
+STORAGES = {
+ "default": {
+ "BACKEND": "django_cf.storage.R2Storage",
+ "OPTIONS": {
+ "binding": "IMAGES",
+ "location": "images",
+ "allow_overwrite": False,
+ },
+ }
+}
+MEDIA_URL = "/media/"
+TEMPLATES = [
+ {
+ "BACKEND": "django.template.backends.django.DjangoTemplates",
+ "APP_DIRS": True,
+ "OPTIONS": {
+ "context_processors": [
+ "django.template.context_processors.request",
+ ],
+ },
+ }
+]
+TIME_ZONE = "UTC"
+USE_TZ = False
diff --git a/django-markdown-r2/src/markdown_project/urls.py b/django-markdown-r2/src/markdown_project/urls.py
new file mode 100644
index 0000000..98e1dc4
--- /dev/null
+++ b/django-markdown-r2/src/markdown_project/urls.py
@@ -0,0 +1,10 @@
+from articles import views
+from django.urls import path
+
+urlpatterns = [
+ path("", views.article_list, name="article-list"),
+ path("articles/new/", views.article_create, name="article-create"),
+ path("articles//", views.article_detail, name="article-detail"),
+ path("articles//edit/", views.article_edit, name="article-edit"),
+ path("media/images/", views.media_image, name="media-image"),
+]
diff --git a/django-markdown-r2/src/markdown_project/wsgi.py b/django-markdown-r2/src/markdown_project/wsgi.py
new file mode 100644
index 0000000..e15c994
--- /dev/null
+++ b/django-markdown-r2/src/markdown_project/wsgi.py
@@ -0,0 +1,7 @@
+import os
+
+from django.core.wsgi import get_wsgi_application
+
+os.environ.setdefault("DJANGO_SETTINGS_MODULE", "markdown_project.settings")
+
+application = get_wsgi_application()
diff --git a/django-markdown-r2/wrangler.jsonc b/django-markdown-r2/wrangler.jsonc
new file mode 100644
index 0000000..e1e46f4
--- /dev/null
+++ b/django-markdown-r2/wrangler.jsonc
@@ -0,0 +1,32 @@
+{
+ "$schema": "node_modules/wrangler/config-schema.json",
+ "name": "django-markdown-r2-worker",
+ "main": "src/entry.py",
+ "compatibility_date": "2026-08-21",
+ "compatibility_flags": [
+ "python_workers"
+ ],
+ "durable_objects": {
+ "bindings": [
+ {
+ "name": "DO_STORAGE",
+ "class_name": "KnowledgeBase"
+ }
+ ]
+ },
+ "migrations": [
+ {
+ "tag": "v1",
+ "new_sqlite_classes": ["KnowledgeBase"]
+ }
+ ],
+ "r2_buckets": [
+ {
+ "binding": "IMAGES",
+ "bucket_name": "django-markdown-r2-images"
+ }
+ ],
+ "observability": {
+ "enabled": true
+ }
+}
diff --git a/tests/test_examples.py b/tests/test_examples.py
index 7ab5377..16597fc 100644
--- a/tests/test_examples.py
+++ b/tests/test_examples.py
@@ -1,4 +1,6 @@
+import re
import subprocess
+import uuid
import pytest
import requests
@@ -284,3 +286,116 @@ def init_django_todo_d1_db():
def test_django_todo_d1(init_django_todo_d1_db, dev_server):
assert_todo_backend(dev_server)
+
+
+def csrf_token(session, base_url, path):
+ response = session.get(f"{base_url}{path}")
+ assert response.status_code == 200
+ match = re.search(r'name="csrfmiddlewaretoken" value="([^"]+)"', response.text)
+ assert match is not None
+ return match.group(1)
+
+
+def test_django_markdown_r2(dev_server):
+ base_url = f"http://localhost:{dev_server}"
+ session = requests.Session()
+ slug = f"article-{uuid.uuid4().hex}"
+
+ response = session.get(base_url)
+ assert response.status_code == 200
+ assert "Articles
" in response.text
+ assert (
+ response.text.count(
+ ''
+ )
+ == 1
+ )
+ assert (
+ session.get(f"{base_url}/articles/missing-{uuid.uuid4().hex}/").status_code
+ == 404
+ )
+
+ token = csrf_token(session, base_url, "/articles/new/")
+ response = session.post(
+ f"{base_url}/articles/new/",
+ data={
+ "csrfmiddlewaretoken": token,
+ "title": "Safe Markdown",
+ "slug": slug,
+ "body": "# Heading\n\n",
+ },
+ allow_redirects=False,
+ )
+ assert response.status_code == 302
+ assert response.headers["Location"] == f"/articles/{slug}/"
+
+ article_list = session.get(base_url)
+ assert article_list.status_code == 200
+ assert "# Heading" in article_list.text
+ assert "<script>" in article_list.text
+ assert "</script>" in article_list.text
+ assert "" not in article_list.text
+
+ response = session.get(f"{base_url}/articles/{slug}/")
+ assert response.status_code == 200
+ assert "Heading
" in response.text
+ assert "<script>alert('unsafe')</script>" in response.text
+ assert "" not in response.text
+
+ token = csrf_token(session, base_url, "/articles/new/")
+ duplicate = session.post(
+ f"{base_url}/articles/new/",
+ data={
+ "csrfmiddlewaretoken": token,
+ "title": "Duplicate",
+ "slug": slug,
+ "body": "Duplicate slug",
+ },
+ )
+ assert duplicate.status_code == 200
+ assert "An article with this slug already exists." in duplicate.text
+
+ image_slug = f"image-{uuid.uuid4().hex}"
+ token = csrf_token(session, base_url, "/articles/new/")
+ image_bytes = b"GIF87a\x01\x00\x01\x00\x80\x00\x00\xff\xff\xff\x00\x00\x00!\xf9\x04\x01\x00\x00\x00\x00,\x00\x00\x00\x00\x01\x00\x01\x00\x00\x02\x02D\x01\x00;"
+ response = session.post(
+ f"{base_url}/articles/new/",
+ data={
+ "csrfmiddlewaretoken": token,
+ "title": "Image article",
+ "slug": image_slug,
+ "body": "An image.",
+ },
+ files={"image": ("pixel.gif", image_bytes, "image/gif")},
+ allow_redirects=False,
+ )
+ assert response.status_code == 302
+
+ detail = session.get(f"{base_url}/articles/{image_slug}/")
+ assert detail.status_code == 200
+ image_match = re.search(r'
', detail.text)
+ assert image_match is not None
+ image = session.get(f"{base_url}{image_match.group(1)}")
+ assert image.status_code == 200
+ assert image.content == image_bytes
+ assert image.headers["Content-Type"] == "image/gif"
+ assert image.headers["Content-Disposition"] == "inline"
+ assert image.headers["X-Content-Type-Options"] == "nosniff"
+ assert session.get(f"{base_url}/media/images/missing.gif").status_code == 404
+
+ token = csrf_token(session, base_url, f"/articles/{slug}/edit/")
+ response = session.post(
+ f"{base_url}/articles/{slug}/edit/",
+ data={
+ "csrfmiddlewaretoken": token,
+ "title": "Updated article",
+ "slug": slug,
+ "body": "Updated body",
+ },
+ allow_redirects=False,
+ )
+ assert response.status_code == 302
+ updated = session.get(f"{base_url}/articles/{slug}/")
+ assert updated.status_code == 200
+ assert "Updated article" in updated.text
+ assert "Updated body" in updated.text
From d8bd5880f4599d85a74ef9e11784360175494458 Mon Sep 17 00:00:00 2001
From: Gyeongjae Choi
Date: Wed, 2 Sep 2026 12:32:54 +0900
Subject: [PATCH 2/5] Use newer django-cf version
---
django-markdown-r2/README.md | 4 ++--
django-markdown-r2/pyproject.toml | 2 +-
.../src/markdown_project/db/__init__.py | 0
.../markdown_project/db/backends/__init__.py | 0
.../db/backends/do/__init__.py | 0
.../markdown_project/db/backends/do/base.py | 24 -------------------
.../src/markdown_project/settings.py | 2 +-
7 files changed, 4 insertions(+), 28 deletions(-)
delete mode 100644 django-markdown-r2/src/markdown_project/db/__init__.py
delete mode 100644 django-markdown-r2/src/markdown_project/db/backends/__init__.py
delete mode 100644 django-markdown-r2/src/markdown_project/db/backends/do/__init__.py
delete mode 100644 django-markdown-r2/src/markdown_project/db/backends/do/base.py
diff --git a/django-markdown-r2/README.md b/django-markdown-r2/README.md
index 624832b..21db15c 100644
--- a/django-markdown-r2/README.md
+++ b/django-markdown-r2/README.md
@@ -7,7 +7,7 @@ A server-rendered knowledge base built with Django on Cloudflare Python Workers.
## Architecture
- `KnowledgeBase` extends [DjangoCFDurableObject](https://pypi.org/project/django-cf/) and `DurableObject`; the Worker forwards every request to the fixed `knowledge-base` instance.
-- A small backend shim extends `django_cf.db.backends.do` so current and older DO SQL result types both work with Django's ORM.
+- Django's ORM uses the Durable Object database backend provided by `django-cf`.
- The Durable Object constructor creates the `articles` table and its index with idempotent SQL.
- `django_cf.storage.R2Storage` uses the `IMAGES` R2 binding with an `images/` key prefix.
- The Worker serves private R2 objects through `GET /media/images/`; images do not need a public R2 bucket URL.
@@ -55,4 +55,4 @@ The Durable Object is provisioned by the migration configuration on deploy. The
## Current limitations
-Python Workers and `django-cf` are currently alpha/open-beta software. The compatibility shim is specific to the pinned `django-cf==0.2.15` release and can be removed when its Durable Object backend accepts Python list results directly. One fixed `knowledge-base` Durable Object provides strong consistency and serializes all database traffic, so this pattern is intended for a small knowledge base rather than high-throughput or globally sharded data. Its schema is created by idempotent SQL in the Durable Object constructor; there is no standard Django migration command. The example does not use Django admin, authentication, sessions, deletion, or image replacement/cleanup. Pyodide does not provide Django's timezone data here, so timestamps are stored as naive UTC values (`USE_TZ = False`).
+Python Workers and `django-cf` are currently alpha/open-beta software. One fixed `knowledge-base` Durable Object provides strong consistency and serializes all database traffic, so this pattern is intended for a small knowledge base rather than high-throughput or globally sharded data. Its schema is created by idempotent SQL in the Durable Object constructor; there is no standard Django migration command. The example does not use Django admin, authentication, sessions, deletion, or image replacement/cleanup. Pyodide does not provide Django's timezone data here, so timestamps are stored as naive UTC values (`USE_TZ = False`).
diff --git a/django-markdown-r2/pyproject.toml b/django-markdown-r2/pyproject.toml
index 0ebe3a9..3806291 100644
--- a/django-markdown-r2/pyproject.toml
+++ b/django-markdown-r2/pyproject.toml
@@ -6,7 +6,7 @@ readme = "README.md"
requires-python = ">=3.13"
dependencies = [
"django",
- "django-cf==0.2.15",
+ "django-cf>=0.2.16",
"markdown-it-py==4.2.0",
]
diff --git a/django-markdown-r2/src/markdown_project/db/__init__.py b/django-markdown-r2/src/markdown_project/db/__init__.py
deleted file mode 100644
index e69de29..0000000
diff --git a/django-markdown-r2/src/markdown_project/db/backends/__init__.py b/django-markdown-r2/src/markdown_project/db/backends/__init__.py
deleted file mode 100644
index e69de29..0000000
diff --git a/django-markdown-r2/src/markdown_project/db/backends/do/__init__.py b/django-markdown-r2/src/markdown_project/db/backends/do/__init__.py
deleted file mode 100644
index e69de29..0000000
diff --git a/django-markdown-r2/src/markdown_project/db/backends/do/base.py b/django-markdown-r2/src/markdown_project/db/backends/do/base.py
deleted file mode 100644
index ca2ad1d..0000000
--- a/django-markdown-r2/src/markdown_project/db/backends/do/base.py
+++ /dev/null
@@ -1,24 +0,0 @@
-from django_cf.db.backends.do.base import DatabaseWrapper as DjangoCFDatabaseWrapper
-from django_cf.db.backends.do.storage import get_storage
-from django_cf.db.base_engine import CFResult
-
-
-class DatabaseWrapper(DjangoCFDatabaseWrapper):
- def run_query(self, query, params=None) -> CFResult:
- processed_query, params = self.process_query(query, params)
- db = get_storage()
- statement = (
- db.exec(processed_query, *params) if params else db.exec(processed_query)
- )
-
- rows = statement.raw().toArray()
- if not isinstance(rows, list):
- rows = rows.to_py()
-
- return CFResult.from_object(
- query,
- params,
- rows,
- statement.rowsRead,
- statement.rowsWritten,
- )
diff --git a/django-markdown-r2/src/markdown_project/settings.py b/django-markdown-r2/src/markdown_project/settings.py
index 4a0662c..9a9a940 100644
--- a/django-markdown-r2/src/markdown_project/settings.py
+++ b/django-markdown-r2/src/markdown_project/settings.py
@@ -11,7 +11,7 @@
]
DATABASES = {
"default": {
- "ENGINE": "markdown_project.db.backends.do",
+ "ENGINE": "django_cf.db.backends.do",
}
}
STORAGES = {
From eae5468d6386e8efcedf0ed2d3b74a80b8fe7d91 Mon Sep 17 00:00:00 2001
From: Gyeongjae Choi
Date: Wed, 2 Sep 2026 12:57:19 +0900
Subject: [PATCH 3/5] tidy up
---
django-markdown-r2/README.md | 6 ++--
django-markdown-r2/src/articles/forms.py | 15 ++------
django-markdown-r2/src/articles/models.py | 13 +++++++
.../templates/articles/article_detail.html | 18 +++++++---
.../templates/articles/article_form.html | 34 +++++++++++++-----
.../templates/articles/article_list.html | 33 ++++++++++++++---
.../src/articles/templates/articles/base.html | 5 ++-
django-markdown-r2/src/articles/views.py | 22 +++++++++---
tests/test_examples.py | 36 +++++++++++--------
9 files changed, 129 insertions(+), 53 deletions(-)
diff --git a/django-markdown-r2/README.md b/django-markdown-r2/README.md
index 21db15c..5071ca7 100644
--- a/django-markdown-r2/README.md
+++ b/django-markdown-r2/README.md
@@ -41,17 +41,17 @@ The Durable Object is provisioned by the migration configuration on deploy. The
| `GET /` | List articles |
| `GET`, `POST /articles/new/` | Create an article and optionally upload an image |
| `GET /articles//` | Render one article |
-| `GET`, `POST /articles//edit/` | Edit title, slug, and Markdown while retaining the existing image |
+| `GET`, `POST /articles//edit/` | Edit the title and Markdown while retaining the stable article URL and existing image |
| `GET /media/images/` | Serve an R2-backed article image |
## Limits and security
-- Titles are limited to 200 characters, slugs to 100 characters, and Markdown to 20,000 characters.
+- Titles are limited to 200 characters and Markdown to 20,000 characters. URL slugs are generated from titles and receive a numeric suffix when needed.
- Image filenames must use a PNG, JPEG, GIF, or WebP extension.
- R2 object names are generated by Django and `allow_overwrite=False` prevents uploads from replacing an existing object.
- The media route validates paths, only sends allowlisted image content types, sets `Content-Disposition: inline` and `X-Content-Type-Options: nosniff`, and returns 404 for absent files.
- `R2Storage` buffers an object while reading it, so this example is intended for small images.
-- Forms use Django CSRF middleware and `{% csrf_token %}`. Duplicate slugs are rejected during form validation.
+- Forms use Django CSRF middleware and `{% csrf_token %}`. Generated slugs remain unchanged when an article title is edited, so existing links stay valid.
## Current limitations
diff --git a/django-markdown-r2/src/articles/forms.py b/django-markdown-r2/src/articles/forms.py
index 835d00f..67e28cf 100644
--- a/django-markdown-r2/src/articles/forms.py
+++ b/django-markdown-r2/src/articles/forms.py
@@ -1,5 +1,3 @@
-from django.core.exceptions import ValidationError
-
from django import forms
from .models import Article
@@ -8,7 +6,7 @@
class ArticleForm(forms.ModelForm):
class Meta:
model = Article
- fields = ["title", "slug", "body", "image"]
+ fields = ["title", "body", "image"]
widgets = {
"body": forms.Textarea(attrs={"rows": 16}),
"image": forms.ClearableFileInput(
@@ -17,16 +15,7 @@ class Meta:
}
help_texts = {"image": "PNG, JPEG, GIF, or WebP."}
- def clean_slug(self):
- slug = self.cleaned_data["slug"]
- matches = Article.objects.filter(slug=slug)
- if self.instance.pk:
- matches = matches.exclude(pk=self.instance.pk)
- if matches.exists():
- raise ValidationError("An article with this slug already exists.")
- return slug
-
class ArticleEditForm(ArticleForm):
class Meta(ArticleForm.Meta):
- fields = ["title", "slug", "body"]
+ fields = ["title", "body"]
diff --git a/django-markdown-r2/src/articles/models.py b/django-markdown-r2/src/articles/models.py
index fd1f6f9..78606ca 100644
--- a/django-markdown-r2/src/articles/models.py
+++ b/django-markdown-r2/src/articles/models.py
@@ -2,6 +2,7 @@
from django.core.validators import FileExtensionValidator
from django.db import models
+from django.utils.text import slugify
def generate_article_id():
@@ -24,3 +25,15 @@ class Article(models.Model):
class Meta:
db_table = "articles"
ordering = ["-created_at"]
+
+ def save(self, *args, **kwargs):
+ if not self.slug:
+ base = (slugify(self.title) or "article")[:100].rstrip("-")
+ candidate = base
+ suffix_number = 2
+ while type(self).objects.filter(slug=candidate).exists():
+ suffix = f"-{suffix_number}"
+ candidate = f"{base[: 100 - len(suffix)].rstrip('-')}{suffix}"
+ suffix_number += 1
+ self.slug = candidate
+ return super().save(*args, **kwargs)
diff --git a/django-markdown-r2/src/articles/templates/articles/article_detail.html b/django-markdown-r2/src/articles/templates/articles/article_detail.html
index b03c61d..9f6f76e 100644
--- a/django-markdown-r2/src/articles/templates/articles/article_detail.html
+++ b/django-markdown-r2/src/articles/templates/articles/article_detail.html
@@ -3,9 +3,17 @@
{% block title %}{{ article.title }} | Knowledge base{% endblock %}
{% block content %}
-
+
{% if article.image %}
-
+
{% endif %}
- {{ rendered_body }}
+
{% endblock %}
diff --git a/django-markdown-r2/src/articles/templates/articles/article_form.html b/django-markdown-r2/src/articles/templates/articles/article_form.html
index 9b8883b..7af1278 100644
--- a/django-markdown-r2/src/articles/templates/articles/article_form.html
+++ b/django-markdown-r2/src/articles/templates/articles/article_form.html
@@ -3,26 +3,42 @@
{% block title %}{% if is_edit %}Edit {{ article.title }}{% else %}New article{% endif %} | Knowledge base{% endblock %}
{% block content %}
-
- {% if is_edit %}Edit article{% else %}New article{% endif %}
+
+ {% if field.help_text %}
+ {{ field.help_text }}
+ {% endif %}
+ {% if field.errors %}
+ {{ field.errors }}
+ {% endif %}
+ {% endif %}
{% endfor %}
-
- Cancel
+
{% endblock %}
diff --git a/django-markdown-r2/src/articles/templates/articles/article_list.html b/django-markdown-r2/src/articles/templates/articles/article_list.html
index 9cf8232..6484503 100644
--- a/django-markdown-r2/src/articles/templates/articles/article_list.html
+++ b/django-markdown-r2/src/articles/templates/articles/article_list.html
@@ -3,20 +3,43 @@
{% block title %}Articles | Knowledge base{% endblock %}
{% block content %}
-
- Articles
+
+
{% if articles %}
{% for article in articles %}
- {{ article.body|truncatechars:180 }}
+
+ {{ article.rendered_body|truncatewords_html:45 }}
+
+
{% endfor %}
{% else %}
- No articles have been published yet.
- Create your first article
+
+
+ The library is ready for its first entry
+
+ No articles have been published yet. Start with a note your future readers will be glad to find.
+
+
{% endif %}
{% endblock %}
diff --git a/django-markdown-r2/src/articles/templates/articles/base.html b/django-markdown-r2/src/articles/templates/articles/base.html
index ff876f5..bb20123 100644
--- a/django-markdown-r2/src/articles/templates/articles/base.html
+++ b/django-markdown-r2/src/articles/templates/articles/base.html
@@ -18,8 +18,11 @@
-
+
{% block content %}{% endblock %}
+