From 7dbae58de47ffa0f67f6d337c8392feae9e756f5 Mon Sep 17 00:00:00 2001 From: Dinesh <13635627+HumbleBee14@users.noreply.github.com> Date: Mon, 13 Jul 2026 05:29:44 -0700 Subject: [PATCH] Add one-click Post to GitHub via a GitHub App (no login, no forks) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Cloudflare Pages Function authenticates as our installed GitHub App (private key in env), then creates a branch, commits the post folder, and opens a PR directly on the repo — always off the latest main. The client just assembles the files (index.mdx, sidecar, images, cover) and POSTs them. Writers click one button; the ZIP download stays as the fallback. Gated by SITE.githubPostEnabled + an origin check on the endpoint. --- functions/api/create-pr.ts | 174 ++++++++++++++++++++++++++++++ src/lib/site.ts | 3 + src/write/WritePortal.tsx | 118 +++++++++++++++++++- src/write/editor/editor-theme.css | 21 ++++ src/write/publish/github.ts | 74 +++++++++++++ 5 files changed, 389 insertions(+), 1 deletion(-) create mode 100644 functions/api/create-pr.ts create mode 100644 src/write/publish/github.ts diff --git a/functions/api/create-pr.ts b/functions/api/create-pr.ts new file mode 100644 index 0000000..784cfac --- /dev/null +++ b/functions/api/create-pr.ts @@ -0,0 +1,174 @@ +// Cloudflare Pages Function: opens a pull request on the repo directly, as our +// installed GitHub App. No user login and no forks — the client sends the post +// files, we authenticate as the App (private key in env) and create the branch, +// commit, and PR. The App's short-lived installation token never leaves here. + +type Env = { + GH_APP_ID?: string; + GH_APP_INSTALLATION_ID?: string; + GH_APP_PRIVATE_KEY?: string; + GH_REPO?: string; + ALLOWED_ORIGIN?: string; +}; + +type PostFile = { path: string; content: string; encoding: 'utf-8' | 'base64' }; + +const DEFAULT_REPO = 'MLSysDev/mlsystems.dev'; +const DEFAULT_ORIGIN = 'https://mlsystems.dev'; +const UA = 'mlsystems-write'; + +function json(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' }, + }); +} + +function b64urlString(s: string): string { + return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +} +function b64urlBytes(bytes: Uint8Array): string { + let bin = ''; + for (const b of bytes) bin += String.fromCharCode(b); + return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +} +function pemToArrayBuffer(pem: string): ArrayBuffer { + const body = pem + .replace(/-----BEGIN [^-]+-----/, '') + .replace(/-----END [^-]+-----/, '') + .replace(/\s+/g, ''); + const bin = atob(body); + const buf = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) buf[i] = bin.charCodeAt(i); + return buf.buffer; +} + +async function appJwt(appId: string, pem: string): Promise { + const key = await crypto.subtle.importKey( + 'pkcs8', + pemToArrayBuffer(pem.replace(/\\n/g, '\n')), + { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' }, + false, + ['sign'], + ); + const now = Math.floor(Date.now() / 1000); + const header = b64urlString(JSON.stringify({ alg: 'RS256', typ: 'JWT' })); + const payload = b64urlString(JSON.stringify({ iat: now - 60, exp: now + 540, iss: appId })); + const data = `${header}.${payload}`; + const sig = await crypto.subtle.sign('RSASSA-PKCS1-v1_5', key, new TextEncoder().encode(data)); + return `${data}.${b64urlBytes(new Uint8Array(sig))}`; +} + +async function gh( + path: string, + token: string, + init: RequestInit = {}, +): Promise> { + const res = await fetch(`https://api.github.com${path}`, { + ...init, + headers: { + accept: 'application/vnd.github+json', + authorization: `Bearer ${token}`, + 'user-agent': UA, + 'content-type': 'application/json', + 'x-github-api-version': '2022-11-28', + ...(init.headers ?? {}), + }, + }); + if (!res.ok) { + const body = (await res.json().catch(() => ({}))) as { message?: string }; + throw new Error(body.message || `GitHub API error (${res.status}).`); + } + return res.status === 204 ? {} : ((await res.json()) as Record); +} + +export async function onRequestPost(context: { request: Request; env: Env }): Promise { + const { request, env } = context; + + const allowed = env.ALLOWED_ORIGIN || DEFAULT_ORIGIN; + const origin = request.headers.get('origin'); + if (origin && origin !== allowed) return json({ error: 'Forbidden origin.' }, 403); + + if (!env.GH_APP_ID || !env.GH_APP_INSTALLATION_ID || !env.GH_APP_PRIVATE_KEY) { + return json({ error: 'Publishing is not configured on the server yet.' }, 500); + } + + let payload: { title?: string; slug?: string; files?: PostFile[] }; + try { + payload = (await request.json()) as typeof payload; + } catch { + return json({ error: 'Invalid request body.' }, 400); + } + const slug = (payload.slug ?? '').trim(); + const title = (payload.title ?? '').trim() || slug; + const files = payload.files ?? []; + if (!slug || files.length === 0) return json({ error: 'Missing post data.' }, 400); + + const [owner, name] = (env.GH_REPO || DEFAULT_REPO).split('/'); + + try { + const jwt = await appJwt(env.GH_APP_ID, env.GH_APP_PRIVATE_KEY); + const inst = (await gh(`/app/installations/${env.GH_APP_INSTALLATION_ID}/access_tokens`, jwt, { + method: 'POST', + })) as { token: string }; + const token = inst.token; + + const ref = (await gh(`/repos/${owner}/${name}/git/ref/heads/main`, token)) as { + object: { sha: string }; + }; + const baseSha = ref.object.sha; + const baseCommit = (await gh(`/repos/${owner}/${name}/git/commits/${baseSha}`, token)) as { + tree: { sha: string }; + }; + + const tree: { path: string; mode: '100644'; type: 'blob'; sha: string }[] = []; + for (const f of files) { + const blob = (await gh(`/repos/${owner}/${name}/git/blobs`, token, { + method: 'POST', + body: JSON.stringify({ + content: f.content, + encoding: f.encoding === 'base64' ? 'base64' : 'utf-8', + }), + })) as { sha: string }; + tree.push({ path: f.path, mode: '100644', type: 'blob', sha: blob.sha }); + } + + const newTree = (await gh(`/repos/${owner}/${name}/git/trees`, token, { + method: 'POST', + body: JSON.stringify({ base_tree: baseCommit.tree.sha, tree }), + })) as { sha: string }; + + const commit = (await gh(`/repos/${owner}/${name}/git/commits`, token, { + method: 'POST', + body: JSON.stringify({ + message: `Add post: ${title}`, + tree: newTree.sha, + parents: [baseSha], + }), + })) as { sha: string }; + + const rand = Math.random().toString(36).slice(2, 8); + const branch = `post/${slug}-${rand}`; + await gh(`/repos/${owner}/${name}/git/refs`, token, { + method: 'POST', + body: JSON.stringify({ ref: `refs/heads/${branch}`, sha: commit.sha }), + }); + + const pr = (await gh(`/repos/${owner}/${name}/pulls`, token, { + method: 'POST', + body: JSON.stringify({ + title: `New post: ${title}`, + head: branch, + base: 'main', + body: 'Submitted through the mlsystems.dev /write portal.\n\nPlease comment your author details (name, short bio, links) so a maintainer can review and publish.', + }), + })) as { html_url: string; number: number }; + + return json({ url: pr.html_url, number: pr.number }); + } catch (err) { + return json( + { error: err instanceof Error ? err.message : 'Could not create the pull request.' }, + 502, + ); + } +} diff --git a/src/lib/site.ts b/src/lib/site.ts index 91ab981..4703baa 100644 --- a/src/lib/site.ts +++ b/src/lib/site.ts @@ -33,6 +33,9 @@ export const SITE = { pitchEmail: 'admin@mlsystems.dev', // Used in nav, footer, etc. startYear: 2026, + // Shows the "Post to GitHub" button in /write. Flip to true once the GitHub App + // credentials are set in the Cloudflare Function env (see /api/create-pr). + githubPostEnabled: false, }; export const APPEARANCE = { diff --git a/src/write/WritePortal.tsx b/src/write/WritePortal.tsx index 91d398a..75b66b9 100644 --- a/src/write/WritePortal.tsx +++ b/src/write/WritePortal.tsx @@ -21,6 +21,11 @@ import { validate } from './serialize/validate'; import { buildZip } from './serialize/toZip'; import { buildSource } from './serialize/source'; import { fetchExisting } from './serialize/fetchExisting'; +import { + assemblePostFiles, + createPullRequest, + isConfigured as isGithubConfigured, +} from './publish/github'; import { allAssets, clearAssets } from './storage/assets'; import { clearDraft, @@ -124,6 +129,11 @@ export default function WritePortal({ authors, topics, repoUrl, contactEmail }: const [openError, setOpenError] = useState(null); const [openUrl, setOpenUrl] = useState(''); const [openDialog, setOpenDialog] = useState(false); + const githubEnabled = isGithubConfigured(); + const [publishOpen, setPublishOpen] = useState(false); + const [publishStage, setPublishStage] = useState<'idle' | 'working' | 'done' | 'error'>('idle'); + const [publishError, setPublishError] = useState(null); + const [prUrl, setPrUrl] = useState(null); const variantCss = useMemo(() => tableVariantCss(tableVariants), [tableVariants]); const images = collectImages(editor.document as unknown as SBlock[]); @@ -286,6 +296,40 @@ export default function WritePortal({ authors, topics, repoUrl, contactEmail }: } }; + const openPublish = () => { + const blocks = editor.document as unknown as SBlock[]; + const found = validate(meta, blocks); + setIssues(found); + if (found.length > 0) return; + setPublishError(null); + setPrUrl(null); + setPublishStage('idle'); + setPublishOpen(true); + }; + + const submitToGithub = async () => { + const blocks = editor.document as unknown as SBlock[]; + setPublishStage('working'); + setPublishError(null); + try { + const serialized = serializePost(meta, blocks, { tableVariants, today: new Date() }); + const sourceJson = buildSource(meta, blocks, tableVariants); + const files = await assemblePostFiles(meta.slug, serialized, sourceJson, allAssets()); + const pr = await createPullRequest({ + slug: meta.slug, + title: meta.title || 'Untitled', + files, + }); + setPrUrl(pr.url); + setPublishStage('done'); + clearDraft(); + await clearStoredAssets().catch(() => undefined); + } catch (err) { + setPublishStage('error'); + setPublishError(err instanceof Error ? err.message : 'Could not create the pull request.'); + } + }; + const slashItems = useMemo(() => getSlashItems(editor), [editor]); // Drop H4–H6 from the block-type dropdown — the serializer only emits h2–h4 @@ -523,10 +567,82 @@ export default function WritePortal({ authors, topics, repoUrl, contactEmail }: {storageOff && ( Autosave is off — your browser blocked storage. )} - + {githubEnabled && ( + + )} + + {publishOpen && ( +
publishStage !== 'working' && setPublishOpen(false)} + role="presentation" + > +
e.stopPropagation()} + > + {publishStage === 'done' ? ( + <> +

Pull request opened ✓

+

+ Your post is submitted as a pull request. A maintainer will review and publish it. +

+
+ {prUrl && ( + + View pull request → + + )} + +
+ + ) : ( + <> +

Post to GitHub

+

+ We’ll open a pull request with your post — images and all. After it’s created, + comment your author details (name, short bio, links) so a maintainer can review + and publish. +

+ {publishError &&

{publishError}

} +
+ + +
+ + )} +
+
+ )} ); } diff --git a/src/write/editor/editor-theme.css b/src/write/editor/editor-theme.css index 8d7e386..bcb4136 100644 --- a/src/write/editor/editor-theme.css +++ b/src/write/editor/editor-theme.css @@ -418,6 +418,27 @@ cursor: default; } +.write-ghost-btn { + display: inline-flex; + align-items: center; + justify-content: center; + background: none; + color: var(--ink-2); + border: 1px solid var(--line-2); + border-radius: 8px; + font-size: 13px; + padding: 10px 20px; + cursor: pointer; +} +.write-ghost-btn:hover:not(:disabled) { + border-color: var(--accent); + color: var(--accent); +} +.write-ghost-btn:disabled { + opacity: 0.6; + cursor: default; +} + .write-done { margin-top: 24px; padding: 18px 20px; diff --git a/src/write/publish/github.ts b/src/write/publish/github.ts new file mode 100644 index 0000000..1ffe3b5 --- /dev/null +++ b/src/write/publish/github.ts @@ -0,0 +1,74 @@ +import { SITE } from '@/lib/site'; +import type { SerializedPost } from '../serialize/toMdx'; + +export function isConfigured(): boolean { + return SITE.githubPostEnabled; +} + +export type PublishFile = { path: string; content: string; encoding: 'utf-8' | 'base64' }; +export type PublishResult = { url: string; number: number }; + +async function fileToBase64(file: File): Promise { + const bytes = new Uint8Array(await file.arrayBuffer()); + let binary = ''; + const chunk = 0x8000; + for (let i = 0; i < bytes.length; i += chunk) { + binary += String.fromCharCode(...bytes.subarray(i, i + chunk)); + } + return btoa(binary); +} + +// Assembles the exact files a post folder needs: index.mdx, the sidecar, any +// component files, and every referenced image (+ cover) — the same set the ZIP ships. +export async function assemblePostFiles( + slug: string, + serialized: SerializedPost, + sourceJson: string, + assets: { name: string; file: File }[], +): Promise { + const dir = `src/content/posts/${slug}`; + const files: PublishFile[] = [ + { path: `${dir}/index.mdx`, content: serialized.mdx, encoding: 'utf-8' }, + { path: `${dir}/.write-source.json`, content: sourceJson, encoding: 'utf-8' }, + ]; + for (const c of serialized.componentFiles) { + files.push({ + path: `${dir}/${c.fileName}`, + content: `${c.source.trimEnd()}\n`, + encoding: 'utf-8', + }); + } + const wanted = new Set(serialized.assetNames); + if (serialized.cover) wanted.add(serialized.cover); + for (const { name, file } of assets) { + if (wanted.has(name)) { + files.push({ path: `${dir}/${name}`, content: await fileToBase64(file), encoding: 'base64' }); + } + } + return files; +} + +// Hands the assembled files to our Cloudflare Function, which opens the PR as the +// GitHub App. No auth here — the App's credentials live server-side only. +export async function createPullRequest(opts: { + slug: string; + title: string; + files: PublishFile[]; +}): Promise { + let data: { url?: string; number?: number; error?: string } = {}; + try { + const res = await fetch('/api/create-pr', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ slug: opts.slug, title: opts.title, files: opts.files }), + }); + data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(data.error || `Request failed (${res.status}).`); + } catch (err) { + throw err instanceof Error ? err : new Error('Could not reach the publishing service.'); + } + if (!data.url || typeof data.number !== 'number') { + throw new Error(data.error || 'The pull request could not be created.'); + } + return { url: data.url, number: data.number }; +}