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 + +[![Deploy to Cloudflare](https://deploy.workers.cloudflare.com/button)](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 %} + +{% 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 %}

+
+ {% csrf_token %} + {% if form.non_field_errors %} + {{ form.non_field_errors }} + {% endif %} +
+ Article details + {% for field in form %} +

+ + {{ field }} + {% if field.help_text %}{{ field.help_text }}{% endif %} + {{ field.errors }} +

+ {% 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 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 %} + + {% 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'Image article', 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 %} -
+
-

{{ article.title }}

+
+

{{ article.title }}

+

+ + Published + · Updated + +

+
{% if article.image %}
- {{ article.title }} + Illustration for {{ article.title }}
{% endif %} - {{ rendered_body }} +
+ {{ 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 is_edit %}Edit article{% else %}New article{% endif %}

+

{% if is_edit %}Refine this entry and save the updated reference for readers.{% else %}Add a clear, useful entry to the knowledge base.{% endif %}

+
{% csrf_token %} {% if form.non_field_errors %} - {{ form.non_field_errors }} +
+ {{ form.non_field_errors }} +
{% endif %}
Article details {% for field in form %} -

+ {% if field.is_hidden %} + {{ field }} + {% if field.errors %} +

{{ field.errors }}
+ {% endif %} + {% else %} {{ field }} - {% if field.help_text %}{{ field.help_text }}{% endif %} - {{ field.errors }} -

+ {% if field.help_text %} + {{ field.help_text }} + {% endif %} + {% if field.errors %} +
{{ field.errors }}
+ {% endif %} + {% endif %} {% endfor %}
- - Cancel +
+ + 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

+
+
+
+

Articles

+

A reference library for practical notes and lasting answers.

+
+

Create article

+
{% if articles %} {% for article in articles %}

{{ article.title }}

+

+ + Updated + +

-

{{ 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 %}
+
+ Knowledge base +
diff --git a/django-markdown-r2/src/articles/views.py b/django-markdown-r2/src/articles/views.py index 0b3817f..61486ad 100644 --- a/django-markdown-r2/src/articles/views.py +++ b/django-markdown-r2/src/articles/views.py @@ -22,10 +22,17 @@ def render_markdown(markdown): return mark_safe(MarkdownIt("js-default").render(markdown)) +def format_date(value): + return f"{value:%B} {value.day}, {value.year}" + + def article_list(request): - return render( - request, "articles/article_list.html", {"articles": Article.objects.all()} - ) + articles = list(Article.objects.all()) + for article in articles: + article.rendered_body = render_markdown(article.body) + article.updated_date = format_date(article.updated_at) + article.updated_iso = article.updated_at.isoformat() + return render(request, "articles/article_list.html", {"articles": articles}) def article_detail(request, slug): @@ -33,7 +40,14 @@ def article_detail(request, slug): return render( request, "articles/article_detail.html", - {"article": article, "rendered_body": render_markdown(article.body)}, + { + "article": article, + "created_date": format_date(article.created_at), + "created_iso": article.created_at.isoformat(), + "rendered_body": render_markdown(article.body), + "updated_date": format_date(article.updated_at), + "updated_iso": article.updated_at.isoformat(), + }, ) diff --git a/tests/test_examples.py b/tests/test_examples.py index 16597fc..4ab663b 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -299,11 +299,13 @@ def csrf_token(session, base_url, path): def test_django_markdown_r2(dev_server): base_url = f"http://localhost:{dev_server}" session = requests.Session() - slug = f"article-{uuid.uuid4().hex}" + article_id = uuid.uuid4().hex + title = f"Safe Markdown {article_id}" + slug = f"safe-markdown-{article_id}" response = session.get(base_url) assert response.status_code == 200 - assert "

Articles

" in response.text + assert '

Articles

' in response.text assert ( response.text.count( '' @@ -316,12 +318,13 @@ def test_django_markdown_r2(dev_server): ) token = csrf_token(session, base_url, "/articles/new/") + create_form = session.get(f"{base_url}/articles/new/") + assert 'name="slug"' not in create_form.text response = session.post( f"{base_url}/articles/new/", data={ "csrfmiddlewaretoken": token, - "title": "Safe Markdown", - "slug": slug, + "title": title, "body": "# Heading\n\n", }, allow_redirects=False, @@ -331,7 +334,7 @@ def test_django_markdown_r2(dev_server): article_list = session.get(base_url) assert article_list.status_code == 200 - assert "# Heading" in article_list.text + assert "

Heading

" in article_list.text assert "<script>" in article_list.text assert "</script>" in article_list.text assert "" not in article_list.text @@ -347,23 +350,23 @@ def test_django_markdown_r2(dev_server): f"{base_url}/articles/new/", data={ "csrfmiddlewaretoken": token, - "title": "Duplicate", - "slug": slug, + "title": title, "body": "Duplicate slug", }, + allow_redirects=False, ) - assert duplicate.status_code == 200 - assert "An article with this slug already exists." in duplicate.text + assert duplicate.status_code == 302 + assert duplicate.headers["Location"] == f"/articles/{slug}-2/" - image_slug = f"image-{uuid.uuid4().hex}" + image_id = uuid.uuid4().hex + image_slug = f"image-article-{image_id}" 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, + "title": f"Image article {image_id}", "body": "An image.", }, files={"image": ("pixel.gif", image_bytes, "image/gif")}, @@ -373,7 +376,10 @@ def test_django_markdown_r2(dev_server): detail = session.get(f"{base_url}/articles/{image_slug}/") assert detail.status_code == 200 - image_match = re.search(r'Image article', detail.text) + image_match = re.search( + rf'Illustration for Image article {image_id}', + detail.text, + ) assert image_match is not None image = session.get(f"{base_url}{image_match.group(1)}") assert image.status_code == 200 @@ -384,17 +390,19 @@ def test_django_markdown_r2(dev_server): assert session.get(f"{base_url}/media/images/missing.gif").status_code == 404 token = csrf_token(session, base_url, f"/articles/{slug}/edit/") + edit_form = session.get(f"{base_url}/articles/{slug}/edit/") + assert 'name="slug"' not in edit_form.text 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 + assert response.headers["Location"] == f"/articles/{slug}/" updated = session.get(f"{base_url}/articles/{slug}/") assert updated.status_code == 200 assert "Updated article" in updated.text From d17eede6482fd71b3cba3e1a8daafca0704918e5 Mon Sep 17 00:00:00 2001 From: Gyeongjae Choi Date: Wed, 2 Sep 2026 13:08:53 +0900 Subject: [PATCH 4/5] add type hints --- django-markdown-r2/README.md | 38 +------------------ django-markdown-r2/pyproject.toml | 2 +- django-markdown-r2/src/articles/models.py | 2 +- .../templates/articles/article_list.html | 10 ----- django-markdown-r2/src/articles/views.py | 18 +++------ django-markdown-r2/src/entry.py | 10 +++-- 6 files changed, 16 insertions(+), 64 deletions(-) diff --git a/django-markdown-r2/README.md b/django-markdown-r2/README.md index 5071ca7..d115a8c 100644 --- a/django-markdown-r2/README.md +++ b/django-markdown-r2/README.md @@ -1,17 +1,8 @@ -# Django Markdown Knowledge Base + Durable Objects + R2 +# Django Markdown Blog + Durable Objects + R2 [![Deploy to Cloudflare](https://deploy.workers.cloudflare.com/button)](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. -- 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. -- Article Markdown is rendered only with `MarkdownIt("js-default")`, which disables raw HTML and unsafe URL schemes. +A blog built with Django on Cloudflare Python Workers, using Durable Objects for database storage and R2 for image storage. ## Local setup @@ -31,28 +22,3 @@ Create an R2 bucket, update the `IMAGES` bucket name in `wrangler.jsonc`, and de ```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 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 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 %}`. Generated slugs remain unchanged when an article title is edited, so existing links stay valid. - -## Current limitations - -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 3806291..04abac5 100644 --- a/django-markdown-r2/pyproject.toml +++ b/django-markdown-r2/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "django-markdown-r2-worker" version = "0.1.0" -description = "Server-rendered Django knowledge base backed by Durable Object SQLite and R2" +description = "Server-rendered Django blog backed by Durable Object SQLite and R2" readme = "README.md" requires-python = ">=3.13" dependencies = [ diff --git a/django-markdown-r2/src/articles/models.py b/django-markdown-r2/src/articles/models.py index 78606ca..52fb6cb 100644 --- a/django-markdown-r2/src/articles/models.py +++ b/django-markdown-r2/src/articles/models.py @@ -5,7 +5,7 @@ from django.utils.text import slugify -def generate_article_id(): +def generate_article_id() -> str: return str(uuid.uuid4()) 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 6484503..f4557d4 100644 --- a/django-markdown-r2/src/articles/templates/articles/article_list.html +++ b/django-markdown-r2/src/articles/templates/articles/article_list.html @@ -30,16 +30,6 @@

{{ article.title }}

{% endfor %} - {% else %} - {% endif %} {% endblock %} diff --git a/django-markdown-r2/src/articles/views.py b/django-markdown-r2/src/articles/views.py index 61486ad..037a7e8 100644 --- a/django-markdown-r2/src/articles/views.py +++ b/django-markdown-r2/src/articles/views.py @@ -1,15 +1,16 @@ +from datetime import datetime 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 django.utils.safestring import SafeString, mark_safe from markdown_it import MarkdownIt from .forms import ArticleEditForm, ArticleForm from .models import Article -IMAGE_CONTENT_TYPES = { +IMAGE_CONTENT_TYPES: dict[str, str] = { ".gif": "image/gif", ".jpeg": "image/jpeg", ".jpg": "image/jpeg", @@ -18,11 +19,11 @@ } -def render_markdown(markdown): +def render_markdown(markdown: str) -> SafeString: return mark_safe(MarkdownIt("js-default").render(markdown)) -def format_date(value): +def format_date(value: datetime) -> str: return f"{value:%B} {value.day}, {value.year}" @@ -74,15 +75,8 @@ def article_edit(request, slug): ) -def media_image(request, name): +def media_image(_request, name: str) -> HttpResponse: 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 diff --git a/django-markdown-r2/src/entry.py b/django-markdown-r2/src/entry.py index 3b90303..448ef1d 100644 --- a/django-markdown-r2/src/entry.py +++ b/django-markdown-r2/src/entry.py @@ -1,7 +1,9 @@ import os from django_cf import DjangoCFDurableObject -from workers import DurableObject, WorkerEntrypoint +from workers import DurableObject, Request, Response, WorkerEntrypoint + +KNOWLEDGE_BASE_NAME: str = "blog" os.environ.setdefault("DJANGO_SETTINGS_MODULE", "markdown_project.settings") from markdown_project.wsgi import application @@ -32,7 +34,7 @@ def get_app(self): class Default(WorkerEntrypoint): - async def fetch(self, request): - id = self.env.DO_STORAGE.idFromName("knowledge-base") - stub = self.env.DO_STORAGE.get(id) + async def fetch(self, request: Request) -> Response: + do_id = self.env.DO_STORAGE.idFromName(KNOWLEDGE_BASE_NAME) + stub = self.env.DO_STORAGE.get(do_id) return await stub.fetch(request) From 257d92f2e16a53a13ff0c8668a8d6b36d6d508b6 Mon Sep 17 00:00:00 2001 From: Gyeongjae Choi Date: Wed, 2 Sep 2026 13:10:52 +0900 Subject: [PATCH 5/5] simplify test --- tests/test_examples.py | 97 ------------------------------------------ 1 file changed, 97 deletions(-) diff --git a/tests/test_examples.py b/tests/test_examples.py index 4ab663b..3936009 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -306,104 +306,7 @@ def test_django_markdown_r2(dev_server): 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/") - create_form = session.get(f"{base_url}/articles/new/") - assert 'name="slug"' not in create_form.text - response = session.post( - f"{base_url}/articles/new/", - data={ - "csrfmiddlewaretoken": token, - "title": title, - "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": title, - "body": "Duplicate slug", - }, - allow_redirects=False, - ) - assert duplicate.status_code == 302 - assert duplicate.headers["Location"] == f"/articles/{slug}-2/" - - image_id = uuid.uuid4().hex - image_slug = f"image-article-{image_id}" - 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": f"Image article {image_id}", - "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( - rf'Illustration for Image article {image_id}', - 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/") - edit_form = session.get(f"{base_url}/articles/{slug}/edit/") - assert 'name="slug"' not in edit_form.text - response = session.post( - f"{base_url}/articles/{slug}/edit/", - data={ - "csrfmiddlewaretoken": token, - "title": "Updated article", - "body": "Updated body", - }, - allow_redirects=False, - ) - assert response.status_code == 302 - assert response.headers["Location"] == f"/articles/{slug}/" - 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