diff --git a/.github/workflows/preview-comment.yml b/.github/workflows/preview-comment.yml new file mode 100644 index 0000000..2cc6b7b --- /dev/null +++ b/.github/workflows/preview-comment.yml @@ -0,0 +1,138 @@ +# Posts (and keeps updated) a sticky comment linking each docs PR to its live preview. +# +# `pull_request_target` so the workflow also runs for fork PRs — safe here because it never checks +# out or executes PR code; it only reads PR metadata and writes a comment. Previews for fork PRs +# stay disabled until a maintainer adds the `preview:enabled` label (enforced server-side too, see +# `server/utils/github.ts`), so the comment either shows the links or explains how to enable them. +name: preview comment + +on: + pull_request_target: + branches: + - main + paths: + - 'playground/content/**' + types: + - opened + - reopened + - synchronize + - labeled + - unlabeled + +permissions: + pull-requests: write + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number }} + +jobs: + comment: + runs-on: ubuntu-latest + + steps: + - name: Create or update preview comment + uses: actions/github-script@v8 + env: + SITE_URL: https://docs-template.comark.dev + CONTENT_DIR: playground/content + PREVIEW_LABEL: preview:enabled + with: + script: | + const marker = '' + const { SITE_URL, CONTENT_DIR, PREVIEW_LABEL } = process.env + const { owner, repo } = context.repo + const pr = context.payload.pull_request + + const internal = pr.head.repo && pr.head.repo.full_name === `${owner}/${repo}` + const enabled = internal || pr.labels.some((label) => label.name === PREVIEW_LABEL) + + let body + if (!enabled) { + body = [ + marker, + '## Documentation previews', + '', + 'Previews are disabled for pull requests from forks.', + `A maintainer can add the \`${PREVIEW_LABEL}\` label to enable them.`, + ].join('\n') + } else { + const previewRoot = `${SITE_URL}/pr/${pr.number}` + const files = await github.paginate(github.rest.pulls.listFiles, { + owner, + repo, + pull_number: pr.number, + per_page: 100, + }) + + const prefix = `${CONTENT_DIR}/` + const pages = files + .filter((file) => + file.status !== 'removed' + && file.filename.startsWith(prefix) + && file.filename.endsWith('.md'), + ) + .map((file) => { + const relativePath = file.filename + .slice(prefix.length) + .replace(/\.md$/, '') + const routeSegments = relativePath + .split('/') + .map((segment) => segment.replace(/^\d+\./, '')) + .map(encodeURIComponent) + if (routeSegments.at(-1) === 'index') { + routeSegments.pop() + } + const route = routeSegments.join('/') + + return { + filename: file.filename, + route, + url: route ? `${previewRoot}/${route}` : `${previewRoot}/`, + } + }) + .sort((a, b) => a.filename.localeCompare(b.filename)) + + body = [ + marker, + '## Documentation previews', + '', + `📚 [Preview all documentation changes](${previewRoot}) (follows new pushes)`, + ...(pages.length + ? [ + '', + ...pages.map((page) => `- [/${page.route}](${page.url})`), + ] + : []), + '', + `Pinned to the current head: [\`${pr.head.sha.slice(0, 7)}\`](${SITE_URL}/blob/${pr.head.sha})`, + ...(internal + ? [] + : ['', `Enabled by the \`${PREVIEW_LABEL}\` label — remove it to disable the preview.`]), + ].join('\n') + } + + const comments = await github.paginate(github.rest.issues.listComments, { + owner, + repo, + issue_number: pr.number, + per_page: 100, + }) + const existingComment = comments.find((comment) => + comment.user?.type === 'Bot' && comment.body?.includes(marker), + ) + + if (existingComment) { + await github.rest.issues.updateComment({ + owner, + repo, + comment_id: existingComment.id, + body, + }) + } else { + await github.rest.issues.createComment({ + owner, + repo, + issue_number: pr.number, + body, + }) + } diff --git a/app/components/docs/DocsPageAsideLinks.vue b/app/components/docs/DocsPageAsideLinks.vue index 52a07b5..387423f 100644 --- a/app/components/docs/DocsPageAsideLinks.vue +++ b/app/components/docs/DocsPageAsideLinks.vue @@ -29,8 +29,9 @@ const links = computed(() => [ label: 'Edit this page on GitHub', to: `${githubUrl.value}/edit/${content.value.mode === 'tree' ? content.value.ref : github?.branch || 'main'}/${github?.contentDir || 'content'}/${props.page.meta.stem}${props.page.meta.extension}`, target: '_blank', - disabled: content.value.mode === 'blob', - class: content.value.mode === 'blob' ? 'text-dimmed hover:text-dimmed cursor-not-allowed' : undefined, + // /blob/ pins a commit and /pr/ may come from a fork branch this site can't link an editor to. + disabled: ['blob', 'pr'].includes(content.value.mode), + class: ['blob', 'pr'].includes(content.value.mode) ? 'text-dimmed hover:text-dimmed cursor-not-allowed' : undefined, }, { icon: 'i-lucide-star', diff --git a/app/composables/useDocsContent.ts b/app/composables/useDocsContent.ts index 17ee0ca..abda709 100644 --- a/app/composables/useDocsContent.ts +++ b/app/composables/useDocsContent.ts @@ -26,9 +26,9 @@ function getClient(basePath: string) { export interface ActiveContent { mode: ContentMode - /** The branch name (tree) or commit SHA (blob); `undefined` in prod. */ + /** The branch name (tree), commit SHA (blob) or PR number (pr); `undefined` in prod. */ ref?: string - /** Link prefix for this version (`/tree/`, `/blob/`, or `''` in prod). */ + /** Link prefix for this version (`/tree/`, `/blob/`, `/pr/`, or `''` in prod). */ base: string /** The path within the content source (with leading slash). */ path: string @@ -61,6 +61,15 @@ export function useDocsContent(): ComputedRef { client: getClient(`/api/content/blob/${route.params.ref}`), } } + if (route.params.number && route.path.startsWith('/pr/')) { + return { + mode: 'pr', + ref: route.params.number as string, + base: `/pr/${route.params.number}`, + path, + client: getClient(`/api/content/pr/${route.params.number}`), + } + } return { mode: 'prod', base: '', path, client: prodContent } }) } diff --git a/app/router.options.ts b/app/router.options.ts index 71efa84..36a66cf 100644 --- a/app/router.options.ts +++ b/app/router.options.ts @@ -1,9 +1,9 @@ import type { RouterConfig } from '@nuxt/schema' /** - * Register the versioned preview routes (`/tree/:ref`, `/blob/:ref` and their `/:slug(.+)` docs variants) as - * real routes reusing the landing and docs page components. They can't be `alias` entries: an alias may not - * introduce a param (`:ref`) the canonical record lacks — Vue Router warns R0102, accurately. + * Register the versioned preview routes (`/tree/:ref`, `/blob/:ref`, `/pr/:number` and their `/:slug(.+)` docs + * variants) as real routes reusing the landing and docs page components. They can't be `alias` entries: an alias + * may not introduce a param (`:ref`) the canonical record lacks — Vue Router warns R0102, accurately. * * Deliberate divergence from comark-content: added there in comarkdown/comark-content#79, reverted to aliases in #83. * The layer was extracted in between and keeps this on purpose — don't "resync" it away. @@ -17,13 +17,15 @@ export default { if (landing) { extra.push( { ...landing, name: 'landing-tree', path: '/tree/:ref' }, - { ...landing, name: 'landing-blob', path: '/blob/:ref' } + { ...landing, name: 'landing-blob', path: '/blob/:ref' }, + { ...landing, name: 'landing-pr', path: '/pr/:number(\\d+)' } ) } if (docs) { extra.push( { ...docs, name: 'docs-tree', path: '/tree/:ref/:slug(.+)' }, - { ...docs, name: 'docs-blob', path: '/blob/:ref/:slug(.+)' } + { ...docs, name: 'docs-blob', path: '/blob/:ref/:slug(.+)' }, + { ...docs, name: 'docs-pr', path: '/pr/:number(\\d+)/:slug(.+)' } ) } diff --git a/app/types/content.ts b/app/types/content.ts index b704bd1..cb40b89 100644 --- a/app/types/content.ts +++ b/app/types/content.ts @@ -1,2 +1,2 @@ /** Which serving mode the active route is in. */ -export type ContentMode = 'prod' | 'tree' | 'blob' +export type ContentMode = 'prod' | 'tree' | 'blob' | 'pr' diff --git a/modules/config.ts b/modules/config.ts index fea7649..d2dcad8 100644 --- a/modules/config.ts +++ b/modules/config.ts @@ -158,8 +158,10 @@ export default defineNuxtModule({ // Layer-owned page (not derived from content/); still SSRs the content navigation shell. '/logos': { isr }, // Previews are served live (SSR) off Runtime Cache; `/blob/**` is immutable commit HTML. + // `/pr/**` follows the PR's head like `/tree/**` follows a branch, so it shares the short TTL. '/tree/**': { isr, robots: 'noindex, nofollow' }, '/blob/**': { isr: true, robots: 'noindex, nofollow' }, + '/pr/**': { isr, robots: 'noindex, nofollow' }, // Raw markdown mirrors of every page, for agents. '/raw/**': { isr, robots: 'noindex' }, // Global content indexes, purged by the push webhook on content changes. @@ -169,6 +171,7 @@ export default defineNuxtModule({ // Fetched on every page hydration (see app.vue) and parses every doc body, so cache it. '/api/content/blob/*/search-sections': { isr: true }, '/api/content/tree/*/search-sections': { isr }, + '/api/content/pr/*/search-sections': { isr }, '/api/content/search-sections': { isr }, '/api/code-explorer/**': { isr }, } diff --git a/nuxt.config.ts b/nuxt.config.ts index d43e6e9..608d46a 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -18,7 +18,7 @@ export default defineNuxtConfig({ ignore: ['content/**'], ui: { content: true, prose: true }, sitemap: { - sources: ['/api/__sitemap__/urls'], exclude: ['/tree/**', '/blob/**'] + sources: ['/api/__sitemap__/urls'], exclude: ['/tree/**', '/blob/**', '/pr/**'] }, ogImage: { zeroRuntime: false }, icon: { diff --git a/playground/content/3.concepts/2.versioned-previews.md b/playground/content/3.concepts/2.versioned-previews.md index 7f88cff..0c7b8c0 100644 --- a/playground/content/3.concepts/2.versioned-previews.md +++ b/playground/content/3.concepts/2.versioned-previews.md @@ -1,14 +1,15 @@ --- title: Versioned previews -description: Browse the docs at any branch or commit through /tree and /blob URLs, without touching production. +description: Browse the docs at any branch, commit or pull request through /tree, /blob and /pr URLs, without touching production. --- -Because content is [addressed by commit](/concepts/architecture#pinned-to-a-commit), the site can render *any* version of your docs on demand. Prefix a URL and you're browsing another branch or commit — no build, no deploy, no separate preview environment. +Because content is [addressed by commit](/concepts/architecture#pinned-to-a-commit), the site can render *any* version of your docs on demand. Prefix a URL and you're browsing another branch, commit or pull request — no build, no deploy, no separate preview environment. | Mode | URL pattern | Content | | --- | --- | --- | | Branch | `/tree/:branch/` | Latest content commit on the branch (follows new pushes) | | Commit | `/blob/:sha/` | That exact commit, immutable | +| Pull request | `/pr/:number/` | The PR's head commit (follows new pushes) | For example, this very page on the `main` branch lives at `/tree/main/concepts/versioned-previews`. @@ -16,11 +17,25 @@ For example, this very page on the `main` branch lives at `/tree/main/concepts/v A preview isn't a filtered view of production — it's a full content instance built from that version's files. The navigation tree, search index, and every internal link are rebuilt from the previewed commit and prefixed with the version base, so you browse a coherent snapshot. A page that only exists on your branch is fully navigable there. -Branch previews resolve the branch to its latest content commit on request, so refreshing a `/tree/` URL after a push shows the new content. Commit previews are immutable and cached accordingly. +Branch previews resolve the branch to its latest content commit on request, so refreshing a `/tree/` URL after a push shows the new content. Commit previews are immutable and cached accordingly. Pull request previews resolve the PR to its head commit on request, so they follow new pushes like branch previews do — including pushes to a fork branch. + +## Fork pull requests + +GitHub shares git objects across the fork network: once a fork opens a pull request, its commits become fetchable through *your* repository's API. Rendering any well-formed SHA would let anyone put arbitrary markdown on your domain by opening a PR from a fork — a phishing vector, even with scripts and embeds stripped. + +So a commit only renders under `/blob/:sha` when at least one of these holds: + +- The commit is in the production branch's history (this keeps version-history links working). +- A pull request from your own repository contains it. Contributors with push access could publish a `/tree/` preview anyway, so their PRs need no extra step. +- A pull request from a fork contains it **and** a maintainer added the `preview:enabled` label to that PR. + +`/pr/:number` follows the same rule: same-repo PRs always render, fork PRs only with the `preview:enabled` label. Removing the label revokes both within about a minute (the decision cache's TTL). + +Every other SHA answers 404, and `/tree/` rejects GitHub's hidden `pull//head` refs, so the label check can't be sidestepped through a branch preview. ## What previews are for -- **Reviewing content PRs** — share a `/tree/my-branch` link instead of asking reviewers to run the site. Pair it with the [PR preview comment action](/deployment/pr-preview-comments) to get links posted automatically. +- **Reviewing content PRs** — share a `/pr/123` or `/tree/my-branch` link instead of asking reviewers to run the site. Pair it with the [PR preview comment action](/deployment/pr-preview-comments) to get links posted automatically. - **Checking history** — the version history panel (keyboard shortcut `g` then `h`, on any docs page) lists the commits that touched the current page and links each one to its `/blob/` preview. - **Debugging** — pin a report to an exact commit with an immutable `/blob/` URL. @@ -28,5 +43,5 @@ Branch previews resolve the branch to its latest content commit on request, so r - Previews are public, like the rest of the site, but send `noindex` robots headers and canonicalize to the production URL — they won't compete with your real pages in search. - The raw Markdown mirrors work in previews too: `/tree/my-branch/raw/.md`. -- "Edit this page on GitHub" targets the previewed branch on `/tree/` pages, and is disabled on `/blob/` pages since a commit can't be edited. +- "Edit this page on GitHub" targets the previewed branch on `/tree/` pages, and is disabled on `/blob/` and `/pr/` pages — a commit can't be edited, and a PR may come from a fork branch the site can't link an editor to. - Preview instances are kept in a small LRU pool per server instance; evicted versions rebuild on demand, and their parsed pages survive in the per-SHA cache. diff --git a/playground/content/4.deployment/2.pr-preview-comments.md b/playground/content/4.deployment/2.pr-preview-comments.md index ce588a6..a3a0130 100644 --- a/playground/content/4.deployment/2.pr-preview-comments.md +++ b/playground/content/4.deployment/2.pr-preview-comments.md @@ -1,19 +1,21 @@ --- title: PR preview comments -description: A GitHub Action that comments on content pull requests with instant /tree/ preview links for every changed page. +description: A GitHub Action that comments on content pull requests with instant preview links for every changed page. --- -Every branch of your docs is already live at [`/tree/:branch`](/concepts/versioned-previews) — nothing to build, nothing to deploy. This optional GitHub Action closes the loop: when a pull request touches `content/`, it posts (and keeps updated) a comment linking each changed page to its live preview. +Every pull request against your docs is already live at [`/pr/:number`](/concepts/versioned-previews) — nothing to build, nothing to deploy. This optional GitHub Action closes the loop: when a pull request touches `content/`, it posts (and keeps updated) a comment linking each changed page to its live preview. + +Pull requests from forks are handled too: their previews stay disabled until a maintainer adds the `preview:enabled` label (see [fork pull requests](/concepts/versioned-previews#fork-pull-requests)), and the comment tells contributors so. Once the label is added, the comment updates itself with the preview links. ## The workflow -Create `.github/workflows/docs-preview-comment.yml` in your docs repository. Replace `https://docs.example.com` with your production URL, and adjust `content/` if your content directory differs: +Create `.github/workflows/docs-preview-comment.yml` in your docs repository. Replace `https://docs.example.com` with your production URL, and adjust `CONTENT_DIR` if your content directory differs: ```yaml [.github/workflows/docs-preview-comment.yml] name: docs preview comment on: - pull_request: + pull_request_target: branches: - main paths: @@ -22,83 +24,108 @@ on: - opened - reopened - synchronize + - labeled + - unlabeled permissions: - contents: read pull-requests: write +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number }} + jobs: comment: - if: github.event.pull_request.head.repo.full_name == github.repository runs-on: ubuntu-latest steps: - name: Create or update preview comment uses: actions/github-script@v8 env: - HEAD_BRANCH: ${{ github.head_ref }} + SITE_URL: https://docs.example.com + CONTENT_DIR: content + PREVIEW_LABEL: preview:enabled with: script: | const marker = '' - const previewRoot = `https://docs.example.com/tree/${encodeURIComponent(process.env.HEAD_BRANCH)}` - + const { SITE_URL, CONTENT_DIR, PREVIEW_LABEL } = process.env const { owner, repo } = context.repo - const pull_number = context.issue.number - const files = await github.paginate(github.rest.pulls.listFiles, { - owner, - repo, - pull_number, - per_page: 100, - }) - - const pages = files - .filter(file => - file.status !== 'removed' - && file.filename.startsWith('content/') - && file.filename.endsWith('.md'), - ) - .map(file => { - const relativePath = file.filename - .slice('content/'.length) - .replace(/\.md$/, '') - const routeSegments = relativePath - .split('/') - .map(segment => segment.replace(/^\d+\./, '')) - .map(encodeURIComponent) - if (routeSegments.at(-1) === 'index') { - routeSegments.pop() - } - const route = routeSegments.join('/') - - return { - filename: file.filename, - route, - url: route ? `${previewRoot}/${route}` : `${previewRoot}/`, - } + const pr = context.payload.pull_request + + const internal = pr.head.repo && pr.head.repo.full_name === `${owner}/${repo}` + const enabled = internal || pr.labels.some((label) => label.name === PREVIEW_LABEL) + + let body + if (!enabled) { + body = [ + marker, + '## Documentation previews', + '', + 'Previews are disabled for pull requests from forks.', + `A maintainer can add the \`${PREVIEW_LABEL}\` label to enable them.`, + ].join('\n') + } else { + const previewRoot = `${SITE_URL}/pr/${pr.number}` + const files = await github.paginate(github.rest.pulls.listFiles, { + owner, + repo, + pull_number: pr.number, + per_page: 100, }) - .sort((a, b) => a.filename.localeCompare(b.filename)) - - const body = [ - marker, - '## Documentation previews', - '', - `📚 [Preview all documentation changes](${previewRoot})`, - ...(pages.length - ? [ - '', - ...pages.map(page => `- [/${page.route}](${page.url})`), - ] - : []), - ].join('\n') - - const issue_number = context.issue.number + + const prefix = `${CONTENT_DIR}/` + const pages = files + .filter((file) => + file.status !== 'removed' + && file.filename.startsWith(prefix) + && file.filename.endsWith('.md'), + ) + .map((file) => { + const relativePath = file.filename + .slice(prefix.length) + .replace(/\.md$/, '') + const routeSegments = relativePath + .split('/') + .map((segment) => segment.replace(/^\d+\./, '')) + .map(encodeURIComponent) + if (routeSegments.at(-1) === 'index') { + routeSegments.pop() + } + const route = routeSegments.join('/') + + return { + filename: file.filename, + route, + url: route ? `${previewRoot}/${route}` : `${previewRoot}/`, + } + }) + .sort((a, b) => a.filename.localeCompare(b.filename)) + + body = [ + marker, + '## Documentation previews', + '', + `📚 [Preview all documentation changes](${previewRoot}) (follows new pushes)`, + ...(pages.length + ? [ + '', + ...pages.map((page) => `- [/${page.route}](${page.url})`), + ] + : []), + '', + `Pinned to the current head: [\`${pr.head.sha.slice(0, 7)}\`](${SITE_URL}/blob/${pr.head.sha})`, + ...(internal + ? [] + : ['', `Enabled by the \`${PREVIEW_LABEL}\` label — remove it to disable the preview.`]), + ].join('\n') + } + const comments = await github.paginate(github.rest.issues.listComments, { owner, repo, - issue_number, + issue_number: pr.number, per_page: 100, }) - const existingComment = comments.find(comment => + const existingComment = comments.find((comment) => comment.user?.type === 'Bot' && comment.body?.includes(marker), ) @@ -113,7 +140,7 @@ jobs: await github.rest.issues.createComment({ owner, repo, - issue_number, + issue_number: pr.number, body, }) } @@ -122,9 +149,16 @@ jobs: ## How it works - The `paths` filter means the workflow only runs when the PR touches `content/`. -- Each changed Markdown file is mapped to its route the same way the site does it: numeric prefixes stripped from every segment, trailing `index` removed. `content/1.getting-started/2.installation.md` becomes `/tree//getting-started/installation`. +- The `labeled` and `unlabeled` triggers refresh the comment when a maintainer toggles `preview:enabled`, so the links appear (or disappear) without a new push. +- Each changed Markdown file is mapped to its route the same way the site does it: numeric prefixes stripped from every segment, trailing `index` removed. `content/1.getting-started/2.installation.md` becomes `/pr//getting-started/installation`. - The comment carries a hidden HTML marker, so subsequent pushes update the existing comment instead of stacking new ones. -- The `if` guard skips forks: the preview URL renders branches of your repository, so fork branches have nothing to link to. Removed files are excluded since their preview would 404. +- Removed files are excluded since their preview would 404. + +::warning +The workflow uses `pull_request_target` so it also runs for fork PRs, which grants the run a write-scoped token. That's safe *only* as long as the job never checks out or executes code from the PR — this one only reads PR metadata and writes a comment. Keep it that way if you extend it. +:: + +The site enforces the label gate server-side too: `/pr/:number` (and `/blob/` of the PR's commits) answer 404 for fork PRs until the label is present, so a comment edited by hand can't expose anything. No token setup is needed — the workflow only uses the built-in `GITHUB_TOKEN` with `pull-requests: write` permission. @@ -134,8 +168,10 @@ Open a pull request that edits a page under `content/`, and the comment appears > ## Documentation previews > -> 📚 [Preview all documentation changes](https://docs.example.com/tree/my-branch) +> 📚 [Preview all documentation changes](https://docs.example.com/pr/42) (follows new pushes) +> +> - [/getting-started/installation](https://docs.example.com/pr/42/getting-started/installation) > -> - [/getting-started/installation](https://docs.example.com/tree/my-branch/getting-started/installation) +> Pinned to the current head: [`4f2a9c1`](https://docs.example.com/blob/4f2a9c1e8b7d6a5f4e3c2b1a0f9e8d7c6b5a4938) -Reviewers can read the rendered pages — navigation, search, and all — before the PR merges, and the links stay current as the branch moves. +Reviewers can read the rendered pages — navigation, search, and all — before the PR merges, and the links stay current as the branch moves. For a fork PR, add the `preview:enabled` label first; the comment updates with the links right after. diff --git a/playground/skills/preview-versions/SKILL.md b/playground/skills/preview-versions/SKILL.md index 75ed60e..7e4e3ed 100644 --- a/playground/skills/preview-versions/SKILL.md +++ b/playground/skills/preview-versions/SKILL.md @@ -1,20 +1,23 @@ --- name: preview-versions description: > - Preview documentation at a branch or commit without changing production. - Use when debugging content on /tree/:branch or /blob/:sha, or explaining - how ISR and GitHub-pinned content work. + Preview documentation at a branch, commit or pull request without changing + production. Use when debugging content on /tree/:branch, /blob/:sha or + /pr/:number, or explaining how ISR and GitHub-pinned content work. --- # Preview versions -Content is served at request time. Production is pinned to a commit SHA; any branch or commit can be previewed through versioned URLs. +Content is served at request time. Production is pinned to a commit SHA; any branch, commit or pull request can be previewed through versioned URLs. | Mode | URL | Content | | --- | --- | --- | | prod | `/getting-started/introduction` | pinned production SHA | | tree | `/tree/main/getting-started/introduction` | latest commit touching the content directory | | blob | `/blob//getting-started/introduction` | immutable commit | +| pr | `/pr//getting-started/introduction` | the PR's head commit (follows new pushes) | + +Commit and PR previews are authorized: a `/blob/` SHA must be in production history or belong to a PR (same-repo, or a fork PR carrying the `preview:enabled` label), and `/pr/` applies the same rule. Unauthorized refs answer 404. Raw markdown mirrors exist at `/raw/**` (and under `/tree/.../raw/` / `/blob/.../raw/`). diff --git a/server/api/content/blob/[sha]/[...path].get.ts b/server/api/content/blob/[sha]/[...path].get.ts index fa15393..a72ef7f 100644 --- a/server/api/content/blob/[sha]/[...path].get.ts +++ b/server/api/content/blob/[sha]/[...path].get.ts @@ -15,7 +15,12 @@ export default defineEventHandler(async (event) => { throw createError({ statusCode: 400, statusMessage: 'Invalid commit SHA' }) } - const content = await getPreviewContent(sha, `/api/content/blob/${sha}`) + // Fork PR commits are fetchable through the upstream repo, so a well-formed SHA is not enough: + // only commits in upstream history or vouched for by a PR may render here (404 otherwise). + // Also resolves short SHAs so one commit pins one content instance. + const fullSha = await authorizePreviewSha(sha) + + const content = await getPreviewContent(fullSha, `/api/content/blob/${sha}`) return await content.handler(toWebRequest(event)) }) diff --git a/server/api/content/pr/[number]/[...path].get.ts b/server/api/content/pr/[number]/[...path].get.ts new file mode 100644 index 0000000..486a679 --- /dev/null +++ b/server/api/content/pr/[number]/[...path].get.ts @@ -0,0 +1,23 @@ +/** + * Per-pull-request data endpoint: `/pr/:number` previews the PR's head commit. Follows new pushes + * (the number → head SHA pointer lives in the short-TTL ref cache) and enforces the preview + * authorization: same-repo PRs always, fork PRs only with the `preview:enabled` label. + */ +export default defineEventHandler(async (event) => { + const rawNumber = getRouterParam(event, 'number') + const path = getRouterParam(event, 'path') + if (!rawNumber || !path) { + throw createError({ statusCode: 400, statusMessage: 'Missing PR number or path' }) + } + + // Public endpoint: every distinct number costs a GitHub API call and a preview-content instance. + const number = parsePullNumber(rawNumber) + if (!number) { + throw createError({ statusCode: 400, statusMessage: 'Invalid PR number' }) + } + + const sha = await resolvePullPreviewSha(number) + const content = await getPreviewContent(sha, `/api/content/pr/${number}`) + + return await content.handler(toWebRequest(event)) +}) diff --git a/server/utils/github.ts b/server/utils/github.ts index 64cb15f..3afe9c5 100644 --- a/server/utils/github.ts +++ b/server/utils/github.ts @@ -109,6 +109,149 @@ export async function resolveContentSha( return sha } +/** Label a maintainer adds to a fork PR to make its commits previewable. */ +export const PREVIEW_LABEL = 'preview:enabled' + +interface GitHubPullSummary { + number: number + head?: { sha?: string; repo?: { full_name?: string } | null } + labels?: Array<{ name?: string }> +} + +function githubHeaders(): Record { + const token = githubToken() + return { + Accept: 'application/vnd.github+json', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + } +} + +/** Whether a definitively missing resource caused this error (vs. a retryable failure). */ +function isNotFound(error: unknown): boolean { + const failure = error as { statusCode?: number; response?: { status?: number } } + const status = failure.statusCode ?? failure.response?.status + // 422 is GitHub's answer for a malformed/unknown object in `/compare` and short-SHA lookups. + return status === 404 || status === 422 +} + +/** + * A PR's commits may be previewed when the PR comes from the content repo itself (its authors could + * push a branch and use `/tree/` anyway) or when a maintainer vouched for it with the preview label. + */ +function pullAllowsPreview(pull: GitHubPullSummary): boolean { + if (pull.head?.repo?.full_name === githubRepo()) return true + return (pull.labels ?? []).some((label) => label.name === PREVIEW_LABEL) +} + +/** The branch production serves — same resolution as `targetBranch()`, inlined to keep this module standalone. */ +function baseBranch(): string { + return process.env.VERCEL_GIT_COMMIT_REF || useRuntimeConfig().docs.github.branch || 'main' +} + +/** + * Authorize a `/blob/:sha` preview and resolve it to the full 40-char SHA. + * + * GitHub shares git objects across the fork network: once a fork opens a PR, its head commit is + * fetchable through the *upstream* repo API — so a bare format check would render any fork's + * markdown on this domain. A SHA is previewable when: + * + * 1. an associated PR allows it (same-repo PR, or a fork PR carrying `preview:enabled`), or + * 2. the commit is in the production branch's history (version-history links). + * + * Decisions live in the short-TTL ref cache — positive ones too, so removing the label revokes + * access within a TTL. Skipped in dev, where refs resolve against the local checkout instead. + */ +export async function authorizePreviewSha(sha: string): Promise { + if (import.meta.dev) return sha + + const key = `preview:sha:${sha}` + const cached = await refStorage.getItem(key) + if (cached === UNRESOLVED) { + throw createError({ statusCode: 404, statusMessage: `No preview available for commit: ${sha}` }) + } + if (cached) return cached + + const deny = async (): Promise => { + await refStorage.setItem(key, UNRESOLVED) + throw createError({ statusCode: 404, statusMessage: `No preview available for commit: ${sha}` }) + } + + // Resolve short SHAs and confirm the commit exists in the repo network at all. + let fullSha: string + try { + const commit = await $fetch<{ sha: string }>( + `https://api.github.com/repos/${githubRepo()}/commits/${sha}`, + { headers: githubHeaders() } + ) + fullSha = commit.sha + } catch (error: unknown) { + if (isNotFound(error)) return deny() + throw error + } + + // PRs associated with the commit — this is how fork PR commits get vouched for. + const pulls = await $fetch( + `https://api.github.com/repos/${githubRepo()}/commits/${fullSha}/pulls`, + { headers: githubHeaders(), query: { per_page: 30 } } + ) + let allowed = pulls.some(pullAllowsPreview) + + // No vouching PR: allow commits already in the production branch's history (version history links). + if (!allowed) { + try { + const comparison = await $fetch<{ status: string }>( + `https://api.github.com/repos/${githubRepo()}/compare/${encodeURIComponent(baseBranch())}...${fullSha}`, + { headers: githubHeaders() } + ) + allowed = comparison.status === 'identical' || comparison.status === 'behind' + } catch (error: unknown) { + if (!isNotFound(error)) throw error + } + } + + if (!allowed) return deny() + + await refStorage.setItem(key, fullSha) + return fullSha +} + +/** + * Authorize a `/pr/:number` preview and resolve it to the PR's head commit SHA. + * + * Same rule as `authorizePreviewSha`: same-repo PRs are always previewable, fork PRs only with the + * `preview:enabled` label. Cached in the short-TTL ref cache so the preview follows new pushes and + * label removal revokes it within a TTL. + */ +export async function resolvePullPreviewSha(number: number): Promise { + const key = `preview:pr:${number}` + const cached = await refStorage.getItem(key) + if (cached === UNRESOLVED) { + throw createError({ statusCode: 404, statusMessage: `No preview available for PR #${number}` }) + } + if (cached) return cached + + const deny = async (): Promise => { + await refStorage.setItem(key, UNRESOLVED) + throw createError({ statusCode: 404, statusMessage: `No preview available for PR #${number}` }) + } + + let pull: GitHubPullSummary + try { + pull = await $fetch(`https://api.github.com/repos/${githubRepo()}/pulls/${number}`, { + headers: githubHeaders(), + }) + } catch (error: unknown) { + if (isNotFound(error)) return deny() + throw error + } + + const sha = pull.head?.sha + if (!sha || !pullAllowsPreview(pull)) return deny() + + await refStorage.setItem(key, sha) + return sha +} + export interface PageCommit { sha: string shortSha: string diff --git a/server/utils/refs.ts b/server/utils/refs.ts index 556d019..5f28569 100644 --- a/server/utils/refs.ts +++ b/server/utils/refs.ts @@ -24,9 +24,20 @@ export function parseBranchName(value: string): string | null { if (branch.startsWith('-') || branch.startsWith('.')) return null if (branch.includes('..') || branch.includes('@{')) return null if (branch.endsWith('/') || branch.endsWith('.lock')) return null + // GitHub resolves the hidden `pull//head` refs (and fully-qualified `refs/...`) wherever a + // branch is accepted, which would let unreviewed fork commits through `/tree/` — PR previews go + // through `/pr/:number`, which enforces the authorization rules. + if (branch.startsWith('pull/') || branch.startsWith('refs/')) return null return branch } +/** A pull request number: all digits, no leading zero, small enough to stay a safe integer. */ +export function parsePullNumber(value: string): number | null { + const trimmed = value.trim() + if (!/^[1-9]\d{0,9}$/.test(trimmed)) return null + return Number(trimmed) +} + /** Either a commit SHA or a branch name — whichever the value looks like. */ export function parseRef(value: string): string | null { return parseCommitSha(value) ?? parseBranchName(value) diff --git a/test/preview-auth.test.ts b/test/preview-auth.test.ts new file mode 100644 index 0000000..be26ac2 --- /dev/null +++ b/test/preview-auth.test.ts @@ -0,0 +1,189 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { authorizePreviewSha, resolvePullPreviewSha } from '../server/utils/github' + +/** + * GitHub shares git objects across the fork network: once a fork opens a PR against the content repo, + * its head commit is fetchable through the *upstream* repo API — so `/blob/:sha` rendering any + * well-formed SHA would render any fork's markdown on this domain. These tests pin the authorization + * rule: same-repo PRs and upstream-history commits pass, fork PRs only with the `preview:enabled` label. + */ + +afterEach(() => { + vi.unstubAllGlobals() +}) + +const REPO = 'comarkdown/comark-docs' + +interface MockRoutes { + /** `commits/:sha` lookup result (also resolves short SHAs). */ + commit?: { sha: string } | Error + /** `commits/:sha/pulls` result. */ + pulls?: Array<{ number: number; head?: { sha?: string; repo?: { full_name?: string } | null }; labels?: Array<{ name?: string }> }> | Error + /** `compare/:base...:sha` result. */ + compare?: { status: string } | Error + /** `pulls/:number` result. */ + pull?: { number: number; head?: { sha?: string; repo?: { full_name?: string } | null }; labels?: Array<{ name?: string }> } | Error +} + +function notFound(): Error { + const error = new Error('Not Found') as Error & { statusCode: number } + error.statusCode = 404 + return error +} + +/** Stub `$fetch` with a URL-dispatched GitHub API. Returns the mock to assert on calls. */ +function stubGitHub(routes: MockRoutes) { + const fetch = vi.fn(async (url: string) => { + const respond = (result: unknown) => { + if (result instanceof Error) throw result + if (result === undefined) throw new Error(`Unmocked GitHub call: ${url}`) + return result + } + if (url.includes('/compare/')) return respond(routes.compare) + if (/\/commits\/[^/]+\/pulls$/.test(url)) return respond(routes.pulls) + if (url.includes(`/repos/${REPO}/commits/`)) return respond(routes.commit) + if (url.includes(`/repos/${REPO}/pulls/`)) return respond(routes.pull) + throw new Error(`Unmocked GitHub call: ${url}`) + }) + vi.stubGlobal('$fetch', fetch) + return fetch +} + +// The decision cache is module-level and shared across tests: every test uses its own SHA/PR number. +let unique = 0 +function sha(): string { + return `${(unique++).toString(16).padStart(4, '0')}a9c1e8b7d6a5f4e3c2b1a0f9e8d7c6b5a4938`.slice(0, 40) +} + +describe('authorizePreviewSha', () => { + it('allows a commit vouched for by a same-repo PR', async () => { + const commit = sha() + stubGitHub({ + commit: { sha: commit }, + pulls: [{ number: 1, head: { sha: commit, repo: { full_name: REPO } } }], + }) + await expect(authorizePreviewSha(commit)).resolves.toBe(commit) + }) + + it('rejects a fork PR commit without the preview label', async () => { + const commit = sha() + stubGitHub({ + commit: { sha: commit }, + pulls: [{ number: 2, head: { sha: commit, repo: { full_name: 'attacker/comark-docs' } }, labels: [] }], + compare: { status: 'diverged' }, + }) + await expect(authorizePreviewSha(commit)).rejects.toMatchObject({ statusCode: 404 }) + }) + + it('allows a fork PR commit once a maintainer adds the preview label', async () => { + const commit = sha() + stubGitHub({ + commit: { sha: commit }, + pulls: [ + { + number: 3, + head: { sha: commit, repo: { full_name: 'contributor/comark-docs' } }, + labels: [{ name: 'preview:enabled' }], + }, + ], + }) + await expect(authorizePreviewSha(commit)).resolves.toBe(commit) + }) + + it('allows a commit in the production branch history (version-history links)', async () => { + const commit = sha() + stubGitHub({ + commit: { sha: commit }, + pulls: [], + compare: { status: 'behind' }, + }) + await expect(authorizePreviewSha(commit)).resolves.toBe(commit) + }) + + it('rejects a commit outside upstream history with no vouching PR', async () => { + const commit = sha() + stubGitHub({ + commit: { sha: commit }, + pulls: [], + compare: notFound(), + }) + await expect(authorizePreviewSha(commit)).rejects.toMatchObject({ statusCode: 404 }) + }) + + it('rejects an unknown commit without further API calls', async () => { + const commit = sha() + const fetch = stubGitHub({ commit: notFound() }) + await expect(authorizePreviewSha(commit)).rejects.toMatchObject({ statusCode: 404 }) + expect(fetch).toHaveBeenCalledTimes(1) + }) + + it('resolves short SHAs to the full commit, so one commit pins one content instance', async () => { + const commit = sha() + stubGitHub({ + commit: { sha: commit }, + pulls: [{ number: 4, head: { sha: commit, repo: { full_name: REPO } } }], + }) + await expect(authorizePreviewSha(commit.slice(0, 7))).resolves.toBe(commit) + }) + + it('caches denials so repeated probes cost no GitHub calls', async () => { + const commit = sha() + const fetch = stubGitHub({ commit: notFound() }) + await expect(authorizePreviewSha(commit)).rejects.toMatchObject({ statusCode: 404 }) + await expect(authorizePreviewSha(commit)).rejects.toMatchObject({ statusCode: 404 }) + expect(fetch).toHaveBeenCalledTimes(1) + }) + + it('does not cache retryable failures (rate limits, 5xx)', async () => { + const commit = sha() + const rateLimited = new Error('rate limited') as Error & { statusCode: number } + rateLimited.statusCode = 403 + const fetch = stubGitHub({ commit: rateLimited }) + await expect(authorizePreviewSha(commit)).rejects.toThrow('rate limited') + + stubGitHub({ + commit: { sha: commit }, + pulls: [{ number: 5, head: { sha: commit, repo: { full_name: REPO } } }], + }) + await expect(authorizePreviewSha(commit)).resolves.toBe(commit) + expect(fetch).toHaveBeenCalledTimes(1) + }) +}) + +describe('resolvePullPreviewSha', () => { + let prNumber = 1000 + + it('resolves a same-repo PR to its head commit', async () => { + const commit = sha() + const number = prNumber++ + stubGitHub({ pull: { number, head: { sha: commit, repo: { full_name: REPO } } } }) + await expect(resolvePullPreviewSha(number)).resolves.toBe(commit) + }) + + it('rejects a fork PR without the preview label', async () => { + const number = prNumber++ + stubGitHub({ + pull: { number, head: { sha: sha(), repo: { full_name: 'attacker/comark-docs' } }, labels: [] }, + }) + await expect(resolvePullPreviewSha(number)).rejects.toMatchObject({ statusCode: 404 }) + }) + + it('resolves a fork PR once it carries the preview label', async () => { + const commit = sha() + const number = prNumber++ + stubGitHub({ + pull: { + number, + head: { sha: commit, repo: { full_name: 'contributor/comark-docs' } }, + labels: [{ name: 'preview:enabled' }], + }, + }) + await expect(resolvePullPreviewSha(number)).resolves.toBe(commit) + }) + + it('rejects an unknown PR number', async () => { + const number = prNumber++ + stubGitHub({ pull: notFound() }) + await expect(resolvePullPreviewSha(number)).rejects.toMatchObject({ statusCode: 404 }) + }) +}) diff --git a/test/preview-registry.test.ts b/test/preview-registry.test.ts index 20f0e0c..aecbfaf 100644 --- a/test/preview-registry.test.ts +++ b/test/preview-registry.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { parseBranchName, parseCommitSha } from '../server/utils/refs' +import { parseBranchName, parseCommitSha, parsePullNumber } from '../server/utils/refs' /** * `/api/content/blob/:sha` and `/api/content/tree/:branch` are unauthenticated, and each distinct ref they accept costs @@ -33,6 +33,30 @@ describe('preview ref boundary', () => { const sha = '4f2a9c1e8b7d6a5f4e3c2b1a0f9e8d7c6b5a4938' expect(blobRoute(sha.toUpperCase())).toBe(blobRoute(sha)) }) + + // GitHub resolves `pull//head` and `refs/...` wherever a branch is accepted, which would let a + // fork PR's commits through `/tree/` without the label check `/pr/:number` enforces. + it('turns away the hidden pull/refs ref namespaces', () => { + for (const ref of ['pull/123/head', 'pull/123/merge', 'refs/pull/123/head', 'refs/heads/main']) { + expect(treeRoute(encodeURIComponent(ref))).toBeNull() + } + // A branch merely *containing* these words is still fine. + expect(treeRoute(encodeURIComponent('feat/pull-based-sync'))).toBe('feat/pull-based-sync') + }) +}) + +/** `/api/content/pr/:number` is public too: only plausible PR numbers may reach the GitHub API. */ +describe('pr number boundary', () => { + it('accepts plausible PR numbers', () => { + expect(parsePullNumber('1')).toBe(1) + expect(parsePullNumber('4823')).toBe(4823) + }) + + it('turns away everything else', () => { + for (const junk of ['', '0', '007', '-1', '1.5', '1e3', 'abc', '12345678901', '123abc']) { + expect(parsePullNumber(junk)).toBeNull() + } + }) }) /**