"""
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 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
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"
]+>", 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
...
, remove the whole paragraph so we don't # leave an empty behind. wrapped = re.search( r"\s*" + re.escape(img_tag) + r"\s*
", html, re.IGNORECASE, ) if wrapped: html = html[: wrapped.start()] + html[wrapped.end():] prepend = f"{img_tag}
\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"), )