Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,15 @@ The parts that bite if you miss them:
- **Links are `django.core.signing` tokens, nothing stored**, and the email tasks take the row,
never a link, since django-q keeps task arguments in the database.

## Guest posts live in `apps/blog/`

`DEVELOPMENT.md` has the details. What bites:

- **A `PostSubmission` is a queue row, never a page.** Posts are frontend MDX, so accepting one
publishes nothing; the admin's `.mdx` download is the editor's starting file.
- **`body_md` is never rendered as HTML on the backend.** Writes go through
`apps/blog/services.py`, and accept/reject are admin actions over `services.review`.

## Email

Templates live in `backend/apps/core/templates/email/<name>/`; `DEVELOPMENT.md` has the
Expand Down
28 changes: 28 additions & 0 deletions DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,7 @@ production. Tests always use the in-memory backend.
| `benchmark_staff_notice` | the same, only when it is waiting for review; links to the admin via `BACKEND_URL` | every active `is_staff` account |
| `benchmark_reviewed` | an editor approves (links to the tool's report) or rejects (passes on the note) | the submitter |
| `comment_staff_notice` | a comment lands awaiting review, or an edit sends a published one back | every active `is_staff` account |
| `post_submission_staff_notice` | a guest post is submitted (`POST /blog/submissions`); links to the admin via `BACKEND_URL` | every active `is_staff` account |
| `comment_reply` | a reply is published - on posting or on approval - and not by the author it answers | the parent comment's author |

A staff member's own benchmark submission approves itself on scoring and mails nobody.
Expand Down Expand Up @@ -592,6 +593,33 @@ What bites:
- Abuse control is `NEWSLETTER_SUBSCRIBE_RATE` per IP: each request mails whatever address it
names.

## Guest posts

The footer's "Submit a post" leads to `/submit-post`. The page is public; the form behind it
needs a signed-in account (the first Google sign-in creates one). It takes a title, summary, tags,
a markdown body in `@uiw/react-md-editor` (loaded client-only, previewing through our own
`<Markdown>`) and a byline: name, role, bio and up to three links.
`POST /blog/submissions` stores a `PostSubmission` (`backend/apps/blog/`) as `pending` and mails
staff. The writer follows its status under "Posts you submitted" on `/activity`.

**A submission is a queue row, never a page.** Posts are MDX in `frontend/content/blog/`, built
into the bundle, so accepting one in the admin publishes nothing. The admin's change page offers
the post as a `.mdx` download with the frontmatter filled in and `draft: true`. The editor still
registers the author in `src/lib/blog/authors.ts`, adds the id, and commits the file.

What bites:

- **`body_md` is never rendered as HTML on the backend.** The admin shows it in a `<pre>`, and the
email carries a plain excerpt.
- **Writes go through `apps/blog/services.py`.** Refusals are `PostSubmissionError`, a
`StaffError`, so they reach the form as a 422 `detail`. Accept and reject are admin actions
calling `services.review`, which stamps `reviewed_by` / `reviewed_at`. Only `review_note`,
which the writer sees, is editable on the change page.
- **Author links go through `validate_external_url`**, the catalog's SSRF guard, though nothing
fetches them today: they end up as links on our site.
- Limits are settings: `BLOG_SUBMISSION_RATE` per account, `BLOG_SUBMISSION_MAX_OPEN` pending
per account, and `BLOG_SUBMISSION_MIN_LENGTH` / `BLOG_SUBMISSION_MAX_LENGTH` on the body.

## Rendering and the API-down build

Catalog routes and `sitemap.ts` declare `export const dynamic = 'force-dynamic'`. The frontend
Expand Down
8 changes: 8 additions & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,14 @@ PUBLIC_MEDIA_URL=http://localhost:8007/media
# Per-IP rate of footer sign-ups; each one mails a confirmation link.
# NEWSLETTER_SUBSCRIBE_RATE=10/hour

# --- Blog submissions -----------------------------------------------------
# Guest posts sent from /submit-post: the per-account rate, how many one account
# may have awaiting review, and the shortest and longest body accepted.
# BLOG_SUBMISSION_RATE=5/day
# BLOG_SUBMISSION_MAX_OPEN=3
# BLOG_SUBMISSION_MIN_LENGTH=300
# BLOG_SUBMISSION_MAX_LENGTH=60000

# --- Benchmarks -----------------------------------------------------------
# Largest redacted PDF a submission may carry, the per-account write rate on
# submissions, the screenshots one run may carry, and the per-run scoring timeout
Expand Down
Empty file added backend/apps/blog/__init__.py
Empty file.
77 changes: 77 additions & 0 deletions backend/apps/blog/admin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
from django.contrib import admin
from django.core.exceptions import PermissionDenied
from django.http import HttpResponse
from django.shortcuts import get_object_or_404
from django.urls import path, reverse
from django.utils.html import format_html
from unfold.admin import ModelAdmin

from apps.blog import mdx, services
from apps.blog.models import PostSubmission, PostSubmissionStatus


@admin.register(PostSubmission)
class PostSubmissionAdmin(ModelAdmin):
list_display = ("title", "author_name", "submitted_by", "status", "created_at")
list_filter = ("status",)
search_fields = ("title", "author_name", "submitted_by__email")
ordering = ("-created_at",)
# What the writer sent is evidence, not a draft to edit here; the decision goes
# through the actions so it is stamped. Only the note to the writer is typed in.
fields = (
"status",
"review_note",
"mdx_export",
"title",
"description",
"tags",
"body",
"author_name",
"author_role",
"author_bio",
"author_links",
"submitted_by",
"created_at",
"reviewed_by",
"reviewed_at",
)
readonly_fields = tuple(f for f in fields if f != "review_note")
actions = ("accept_submissions", "reject_submissions")

@admin.action(description="Accept - turn into MDX by hand")
def accept_submissions(self, request, queryset):
for submission in queryset:
services.review(submission, user=request.user, status=PostSubmissionStatus.ACCEPTED)
self.message_user(request, f"Accepted {queryset.count()} posts.")

@admin.action(description="Reject - keep the row, so the writer sees the outcome")
def reject_submissions(self, request, queryset):
for submission in queryset:
services.review(submission, user=request.user, status=PostSubmissionStatus.REJECTED)
self.message_user(request, f"Rejected {queryset.count()} posts.")

@admin.display(description="Body (markdown)")
def body(self, obj):
return format_html('<pre style="white-space: pre-wrap">{}</pre>', obj.body_md)

@admin.display(description="MDX")
def mdx_export(self, obj):
url = reverse("admin:blog_postsubmission_mdx", args=[obj.pk])
return format_html('<a href="{}">Download {}</a>', url, mdx.filename(obj))

def get_urls(self):
export = path(
"<int:pk>/mdx/",
self.admin_site.admin_view(self.export_mdx),
name="blog_postsubmission_mdx",
)
return [export, *super().get_urls()]

def export_mdx(self, request, pk):
"""The post as a file to drop into `frontend/content/blog/` and finish there."""
if not self.has_view_permission(request):
raise PermissionDenied
submission = get_object_or_404(PostSubmission, pk=pk)
response = HttpResponse(mdx.render(submission), content_type="text/markdown")
response["Content-Disposition"] = f'attachment; filename="{mdx.filename(submission)}"'
return response
40 changes: 40 additions & 0 deletions backend/apps/blog/api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
"""Guest post submissions. Blog posts themselves are MDX in the frontend, so a
submission is a queue row for staff to read, never a page."""

from django.http import HttpRequest
from ninja import Router, Status

from apps.accounts.api import JWTAuth
from apps.blog import services
from apps.blog.schemas import PostSubmissionIn, PostSubmissionOut
from apps.blog.throttles import PostSubmissionThrottle
from apps.catalog.services import client_ip

router = Router(tags=["blog"])


@router.post(
"/submissions",
response={201: PostSubmissionOut},
auth=JWTAuth(),
throttle=[PostSubmissionThrottle()],
summary="Submit a blog post for review",
)
def create_post_submission(request: HttpRequest, payload: PostSubmissionIn):
submission = services.submit_post(
user=request.auth,
data=payload.dict(),
source_ip=client_ip(request),
user_agent=request.META.get("HTTP_USER_AGENT", ""),
)
return Status(201, submission)


@router.get(
"/submissions",
response=list[PostSubmissionOut],
auth=JWTAuth(),
summary="Blog posts you have submitted",
)
def list_my_post_submissions(request: HttpRequest):
return request.auth.post_submissions.all()
7 changes: 7 additions & 0 deletions backend/apps/blog/apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
from django.apps import AppConfig


class BlogConfig(AppConfig):
name = "apps.blog"
label = "blog"
verbose_name = "Blog"
48 changes: 48 additions & 0 deletions backend/apps/blog/emails.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
"""The staff notice for each guest post. Queued, so the writer's request never waits
on or fails with the mail provider."""

from django.conf import settings
from django.db import transaction
from django.urls import reverse
from django.utils.text import Truncator

from apps.blog.models import PostSubmission
from apps.core.email import send_templated_email, staff_addresses

EXCERPT_CHARS = 600


def queue_staff_notice(submission):
from django_q.tasks import async_task

# The pk, never the row: django-q keeps task arguments in the database.
transaction.on_commit(
lambda: async_task(
"apps.blog.emails.send_staff_notice",
submission.pk,
task_name=f"post submission notice {submission.pk}",
)
)


def send_staff_notice(submission_pk):
staff = staff_addresses()
if not staff:
return

submission = PostSubmission.objects.select_related("submitted_by").get(pk=submission_pk)
send_templated_email(
"post_submission_staff_notice",
{
"title": submission.title,
"description": submission.description,
"author_name": submission.author_name,
"author_role": submission.author_role,
"tags": ", ".join(submission.tags),
"excerpt": Truncator(submission.body_md).chars(EXCERPT_CHARS),
"submitter_email": submission.submitted_by.email,
"admin_url": settings.BACKEND_URL
+ reverse("admin:blog_postsubmission_change", args=[submission.pk]),
},
to=staff,
)
34 changes: 34 additions & 0 deletions backend/apps/blog/mdx.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
"""A guest post as a starting MDX file for `frontend/content/blog/`.

Values are JSON-quoted, which YAML reads as plain strings, so a colon or a quote in
a title cannot break the frontmatter. `authors` is left for the editor: the blog
only accepts ids registered in `src/lib/blog/authors.ts`.
"""

import json

from django.utils import timezone
from django.utils.text import slugify


def filename(submission):
return f"{slugify(submission.title) or f'post-{submission.pk}'}.mdx"


def render(submission):
byline = ", ".join(part for part in (submission.author_name, submission.author_role) if part)
lines = [
"---",
f"title: {json.dumps(submission.title)}",
f"description: {json.dumps(submission.description)}",
f"date: {json.dumps(timezone.localdate().isoformat())}",
f"tags: {json.dumps(submission.tags)}",
"draft: true",
f"# authors: register {json.dumps(byline)} in src/lib/blog/authors.ts, then add the id",
*(f"# author link: {link}" for link in submission.author_links),
"---",
"",
submission.body_md.strip(),
"",
]
return "\n".join(lines)
43 changes: 43 additions & 0 deletions backend/apps/blog/migrations/0001_initial.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# Generated by Django 6.1.1 on 2026-10-08 19:25

import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models


class Migration(migrations.Migration):

initial = True

dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]

operations = [
migrations.CreateModel(
name='PostSubmission',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('title', models.CharField(max_length=200)),
('description', models.CharField(help_text="The post's summary line.", max_length=300)),
('tags', models.JSONField(blank=True, default=list)),
('body_md', models.TextField(help_text='Raw markdown. Never rendered as HTML on the backend.')),
('author_name', models.CharField(max_length=120)),
('author_role', models.CharField(blank=True, max_length=120)),
('author_bio', models.TextField(blank=True)),
('author_links', models.JSONField(blank=True, default=list)),
('source_ip', models.GenericIPAddressField(blank=True, null=True)),
('user_agent', models.CharField(blank=True, max_length=512)),
('status', models.CharField(choices=[('pending', 'Pending'), ('accepted', 'Accepted'), ('rejected', 'Rejected')], db_index=True, default='pending', max_length=16)),
('reviewed_at', models.DateTimeField(blank=True, null=True)),
('review_note', models.TextField(blank=True, help_text='Shown to the submitter.')),
('reviewed_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
('submitted_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='post_submissions', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ('-created_at',),
},
),
]
Empty file.
54 changes: 54 additions & 0 deletions backend/apps/blog/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
from django.conf import settings
from django.db import models

from apps.core.models import TimeStampedModel


class PostSubmissionStatus(models.TextChoices):
PENDING = "pending", "Pending"
ACCEPTED = "accepted", "Accepted"
REJECTED = "rejected", "Rejected"


class PostSubmission(TimeStampedModel):
"""A guest post awaiting review. Accepting one publishes nothing: posts are MDX
in the frontend, so an editor still turns it into a file and commits it."""

title = models.CharField(max_length=200)
description = models.CharField(max_length=300, help_text="The post's summary line.")
tags = models.JSONField(default=list, blank=True)
body_md = models.TextField(help_text="Raw markdown. Never rendered as HTML on the backend.")

author_name = models.CharField(max_length=120)
author_role = models.CharField(max_length=120, blank=True)
author_bio = models.TextField(blank=True)
author_links = models.JSONField(default=list, blank=True)

# Login is required, so identity is the account rather than a typed address.
submitted_by = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="post_submissions"
)
source_ip = models.GenericIPAddressField(null=True, blank=True)
user_agent = models.CharField(max_length=512, blank=True)

status = models.CharField(
max_length=16,
choices=PostSubmissionStatus.choices,
default=PostSubmissionStatus.PENDING,
db_index=True,
)
reviewed_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="+",
)
reviewed_at = models.DateTimeField(null=True, blank=True)
review_note = models.TextField(blank=True, help_text="Shown to the submitter.")

class Meta:
ordering = ("-created_at",)

def __str__(self):
return self.title
Loading
Loading