Initial commit: medium-proxy for Postiz → Medium pipeline

This commit is contained in:
alexandrev-tibco
2026-05-08 17:59:37 +02:00
commit 843cf9a12d
8 changed files with 424 additions and 0 deletions
+11
View File
@@ -0,0 +1,11 @@
# TLS private keys — never commit these
certs/ca.key
certs/server.key
certs/server.csr
certs/server.ext
certs/ca.srl
# Python
__pycache__/
*.pyc
.venv/
+19
View File
@@ -0,0 +1,19 @@
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY main.py .
# TLS certs are injected at runtime via a volume mount:
# ./certs/server.key — private key for api.medium.com
# ./certs/server.crt — cert signed by our local CA
# Run setup.sh once to generate them.
CMD ["uvicorn", "main:app", \
"--host", "0.0.0.0", \
"--port", "443", \
"--ssl-keyfile", "/certs/server.key", \
"--ssl-certfile", "/certs/server.crt", \
"--log-level", "info"]
+113
View File
@@ -0,0 +1,113 @@
# medium-proxy
Transparent HTTPS proxy that sits between [Postiz](https://postiz.com) and `api.medium.com`.
When Postiz publishes a post to Medium it sends raw Markdown. This proxy intercepts the request and applies two fixes before forwarding to Medium's real API:
| Problem | Fix |
|---------|-----|
| Tables render as plain text with `\|` | Converts Markdown → HTML (`contentFormat: 'html'`) |
| Stories show a pattern thumbnail instead of a real image | Moves the first `<img>` to the top of the content |
Everything else (authentication, tags, canonical URL, publish status) is forwarded unchanged.
---
## How it works
```
Postiz container
│ fetch("https://api.medium.com/v1/users/.../posts")
│ [extra_hosts redirects api.medium.com → 172.19.0.100]
medium-proxy:443 (self-signed cert for api.medium.com, trusted via NODE_EXTRA_CA_CERTS)
│ - markdown → HTML
│ - featured image to top
api.medium.com (real Medium API)
```
---
## First-time deployment
**On the Postiz LXC (192.168.1.90):**
```bash
# 1. Clone this repo
cd /root
git clone https://gitea.alexandre-vazquez.cloud/alexandrev/medium-proxy.git
cd medium-proxy
# 2. Generate TLS certs, build image, start container
chmod +x setup.sh
./setup.sh
# 3. Apply the snippet printed by setup.sh to /root/postiz/docker-compose.yaml
# (adds extra_hosts, NODE_EXTRA_CA_CERTS, and volume mount for the CA cert)
# 4. Restart only the postiz service
cd /root/postiz && docker compose up -d postiz
```
### Postiz docker-compose.yaml patch
Add the following inside the `postiz` service block:
```yaml
extra_hosts:
- "api.medium.com:172.19.0.100"
environment:
NODE_EXTRA_CA_CERTS: /certs/ca.crt
volumes:
- /root/medium-proxy/certs/ca.crt:/certs/ca.crt:ro
```
---
## Updating
```bash
cd /root/medium-proxy
git pull
docker compose up -d --build
```
No need to touch the Postiz docker-compose again — certs are reused.
---
## Redeploying from scratch (e.g. after losing the LXC)
```bash
cd /root
git clone https://gitea.alexandre-vazquez.cloud/alexandrev/medium-proxy.git
cd medium-proxy
./setup.sh # generates new certs
# Apply the printed snippet to /root/postiz/docker-compose.yaml
# Restart postiz
```
> **Note:** New certs mean a new CA. Don't forget to restart the postiz container after updating the CA cert mount so Node.js picks it up (`NODE_EXTRA_CA_CERTS` is read at startup).
---
## Verifying it works
```bash
# Check proxy is running
docker logs medium-proxy
# Send a test publish from Postiz and look for the conversion log line:
# INFO POST /v1/users/.../posts | contentFormat: markdown → html
docker logs -f medium-proxy
```
---
## Limitations
- **Paywall**: Medium's public API (v1) has no endpoint to toggle the Partner Program paywall. Enable it manually for each story from the Medium web editor.
- **Featured image from WordPress**: The proxy moves the first image already present in the article body. If the WordPress featured image is not embedded in the article content (only stored as a post thumbnail), the proxy has no way to retrieve it — the story will still show the pattern thumbnail.
- **Post updates**: Medium's v1 API is create-only. Already-published stories with broken tables or missing images must be fixed manually via the Medium web editor.
View File
+30
View File
@@ -0,0 +1,30 @@
# medium-proxy — standalone docker-compose
#
# Joins the existing Postiz Docker network so the proxy is reachable
# from the postiz container at a fixed IP (172.19.0.100).
#
# Prerequisites:
# 1. Run ./setup.sh to generate TLS certs (one-time)
# 2. Apply the Postiz docker-compose patch described in README.md
#
# Deploy: docker compose up -d --build
# Logs: docker compose logs -f
# Redeploy: docker compose up -d --build (after git pull)
services:
medium-proxy:
build: .
container_name: medium-proxy
restart: always
networks:
postiz-network:
ipv4_address: 172.19.0.100 # Fixed IP — must match extra_hosts in Postiz
volumes:
- ./certs:/certs:ro # TLS certs generated by setup.sh
networks:
# Attach to the Postiz network that Docker created under /root/postiz/
# The name follows Docker Compose's convention: <project>_<network>
postiz-network:
name: postiz_postiz-network
external: true
+171
View File
@@ -0,0 +1,171 @@
"""
medium-proxy — transparent HTTPS proxy between Postiz and api.medium.com
Intercepts POST requests to Medium's post creation endpoints and applies
two transformations before forwarding to the real API:
1. Markdown → HTML conversion (fixes table rendering — Medium's API does
not render GFM tables when contentFormat is 'markdown')
2. Featured image placement — moves the first <img> to the top of the
content so Medium picks it up as the story cover
All other requests (GET /v1/me, publications, etc.) are forwarded unchanged.
"""
import json
import logging
import re
import httpx
import markdown
from fastapi import FastAPI, Request, Response
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
logger = logging.getLogger("medium-proxy")
MEDIUM_API_BASE = "https://api.medium.com"
# HTTP hop-by-hop headers that must not be forwarded
HOP_BY_HOP = {
"host", "content-length", "transfer-encoding", "connection",
"keep-alive", "proxy-authenticate", "proxy-authorization",
"te", "trailers", "upgrade",
}
app = FastAPI(title="medium-proxy", docs_url=None, redoc_url=None)
# ── Content transformations ───────────────────────────────────────────────────
def markdown_to_html(content: str) -> str:
"""
Convert Markdown to HTML with GFM table and fenced code block support.
Medium's Markdown renderer ignores pipe-table syntax, producing raw '|'
characters in the published story. Sending HTML with contentFormat='html'
is the only reliable way to get proper table rendering.
"""
return markdown.markdown(
content,
extensions=["tables", "fenced_code"],
)
def move_first_image_to_top(html: str) -> str:
"""
Ensure the first <img> element appears at the very beginning of the HTML.
Medium treats the first image in a story as the cover/featured image shown
in listings and social previews. Without this, stories published via the
API show a generated pattern instead of the article's real thumbnail.
If no image is found, the HTML is returned unchanged.
"""
img_match = re.search(r"<img[^>]+>", html, re.IGNORECASE)
if not img_match:
return html
img_tag = img_match.group(0)
# Already at the start — nothing to move
if img_match.start() == 0:
return html
# Remove the image from its current position.
# If it sits inside a <p>...</p>, remove the whole paragraph so we don't
# leave an empty <p></p> behind.
wrapped = re.search(
r"<p>\s*" + re.escape(img_tag) + r"\s*</p>",
html,
re.IGNORECASE,
)
if wrapped:
html = html[: wrapped.start()] + html[wrapped.end():]
prepend = f"<p>{img_tag}</p>\n"
else:
pos = html.find(img_tag)
html = html[:pos] + html[pos + len(img_tag):]
prepend = img_tag + "\n"
return prepend + html
def process_post_body(body: dict) -> dict:
"""
Transform a Medium post creation request body.
Only modifies requests where contentFormat is 'markdown'. Returns a new
dict; the original is never mutated.
"""
if body.get("contentFormat") != "markdown":
return body
content = body.get("content", "")
html = markdown_to_html(content)
html = move_first_image_to_top(html)
logger.info("Converted markdown → html (tables + featured image)")
return {**body, "content": html, "contentFormat": "html"}
# ── Proxy route ───────────────────────────────────────────────────────────────
@app.api_route(
"/{path:path}",
methods=["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"],
)
async def proxy(request: Request, path: str):
"""
Forward every request to api.medium.com.
POST requests to endpoints containing 'posts' are processed before forwarding.
"""
body_bytes = await request.body()
body_json = None
is_post_creation = request.method == "POST" and "posts" in path
if request.method in ("POST", "PUT", "PATCH") and body_bytes:
try:
body_json = json.loads(body_bytes)
if is_post_creation:
original_fmt = body_json.get("contentFormat", "?")
body_json = process_post_body(body_json)
logger.info(
"POST /%s | contentFormat: %s%s",
path, original_fmt, body_json.get("contentFormat"),
)
except (json.JSONDecodeError, ValueError):
# Not JSON — will forward raw bytes
pass
forward_headers = {
k: v for k, v in request.headers.items()
if k.lower() not in HOP_BY_HOP
}
async with httpx.AsyncClient(timeout=30.0) as client:
kwargs: dict = dict(
method=request.method,
url=f"{MEDIUM_API_BASE}/{path}",
headers=forward_headers,
params=dict(request.query_params),
)
if body_json is not None:
kwargs["json"] = body_json
elif body_bytes:
kwargs["content"] = body_bytes
upstream = await client.request(**kwargs)
# Strip hop-by-hop + content-encoding from upstream response
resp_headers = {
k: v for k, v in upstream.headers.items()
if k.lower() not in HOP_BY_HOP | {"content-encoding"}
}
return Response(
content=upstream.content,
status_code=upstream.status_code,
headers=resp_headers,
media_type=upstream.headers.get("content-type"),
)
+4
View File
@@ -0,0 +1,4 @@
fastapi==0.111.0
uvicorn[standard]==0.30.1
httpx==0.27.0
Markdown==3.6
+76
View File
@@ -0,0 +1,76 @@
#!/usr/bin/env bash
# ============================================================
# setup.sh — First-time deployment of medium-proxy
#
# Run once from /root/medium-proxy on the Postiz LXC (192.168.1.90).
# After this, docker compose up -d --build is enough for updates.
#
# What it does:
# 1. Generates a local CA cert + a server cert for api.medium.com
# 2. Builds and starts the medium-proxy Docker container
# 3. Prints the snippet to add to /root/postiz/docker-compose.yaml
# ============================================================
set -euo pipefail
CERT_DIR="./certs"
PROXY_IP="172.19.0.100"
POSTIZ_COMPOSE="/root/postiz/docker-compose.yaml"
# ── 1. Generate TLS certs ────────────────────────────────────────────────────
mkdir -p "$CERT_DIR"
echo "→ Generating CA certificate..."
openssl genrsa -out "$CERT_DIR/ca.key" 4096 2>/dev/null
openssl req -new -x509 \
-key "$CERT_DIR/ca.key" \
-out "$CERT_DIR/ca.crt" \
-days 3650 \
-subj "/CN=Medium Proxy CA/O=Homelab" 2>/dev/null
echo "$CERT_DIR/ca.crt"
echo "→ Generating server certificate for api.medium.com..."
openssl genrsa -out "$CERT_DIR/server.key" 2048 2>/dev/null
openssl req -new \
-key "$CERT_DIR/server.key" \
-out "$CERT_DIR/server.csr" \
-subj "/CN=api.medium.com" 2>/dev/null
# SAN extension required — modern TLS clients reject certs without it
cat > "$CERT_DIR/server.ext" <<'EXTEOF'
subjectAltName=DNS:api.medium.com
EXTEOF
openssl x509 -req \
-in "$CERT_DIR/server.csr" \
-CA "$CERT_DIR/ca.crt" \
-CAkey "$CERT_DIR/ca.key" \
-CAcreateserial \
-out "$CERT_DIR/server.crt" \
-days 3650 \
-extfile "$CERT_DIR/server.ext" 2>/dev/null
echo "$CERT_DIR/server.crt"
# ── 2. Build and start the proxy ─────────────────────────────────────────────
echo "→ Building and starting medium-proxy..."
docker compose up -d --build
echo " ✓ medium-proxy running at $PROXY_IP:443"
# ── 3. Print the Postiz docker-compose patch ─────────────────────────────────
cat << PATCHEOF
════════════════════════════════════════════════════════════════
Add this to the 'postiz' service in $POSTIZ_COMPOSE
════════════════════════════════════════════════════════════════
extra_hosts:
- "api.medium.com:${PROXY_IP}"
environment:
NODE_EXTRA_CA_CERTS: /certs/ca.crt
volumes:
- /root/medium-proxy/certs/ca.crt:/certs/ca.crt:ro
Then restart only the postiz service (no data loss):
cd /root/postiz && docker compose up -d postiz
════════════════════════════════════════════════════════════════
PATCHEOF