Initial commit: medium-proxy for Postiz → Medium pipeline
This commit is contained in:
+11
@@ -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
@@ -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"]
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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"),
|
||||
)
|
||||
@@ -0,0 +1,4 @@
|
||||
fastapi==0.111.0
|
||||
uvicorn[standard]==0.30.1
|
||||
httpx==0.27.0
|
||||
Markdown==3.6
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user