From d8371f20a8618ff3d49b2d9a73f0241afaa273ba Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Tue, 13 Oct 2026 22:53:07 +0100 Subject: [PATCH] refactor: migrate to use `forges` (#1986) --- .../runtime/server/auth/github/[slug].ts | 28 +----- modules/runtime/server/slides/[id].pdf.ts | 43 ++------- modules/slides.ts | 23 +---- nuxt.config.ts | 3 +- package.json | 1 + pnpm-lock.yaml | 10 ++ server/api/admin/github-releases.get.ts | 28 ++---- server/api/update-location.post.ts | 8 +- server/routes/auth/[...provider].get.ts | 62 ++++--------- server/utils/github.ts | 93 ++++++++++++++++--- server/utils/sponsors.ts | 40 +------- test/unit/bundle.spec.ts | 2 +- test/unit/github.spec.ts | 75 +++++++++++++++ 13 files changed, 214 insertions(+), 202 deletions(-) create mode 100644 test/unit/github.spec.ts diff --git a/modules/invites/runtime/server/auth/github/[slug].ts b/modules/invites/runtime/server/auth/github/[slug].ts index d375bf4d..38b27b9b 100644 --- a/modules/invites/runtime/server/auth/github/[slug].ts +++ b/modules/invites/runtime/server/auth/github/[slug].ts @@ -41,8 +41,8 @@ export default defineEventHandler(async event => { }) } - const username = await query(access_token, `{ viewer { login } }`) - .then(r => r?.viewer.login) + const username = await createGitHub(access_token).users.me() + .then(user => user.login) .catch(err => { console.error('viewer', err) return null @@ -55,27 +55,5 @@ export default defineEventHandler(async event => { }) } - try { - const res = await fetch( - `https://api.github.com/repos/${repo}/collaborators/${encodeURIComponent(username)}`, - { - method: 'PUT', - body: JSON.stringify({ permission: 'push' }), - headers: { - 'User-Agent': 'https://roe.dev', - 'Content-Type': 'application/json', - 'Accept': 'application/vnd.github+json', - 'X-GitHub-Api-Version': '2022-11-28', - 'Authorization': `Bearer ${config.github.inviteToken}`, - }, - }, - ) - - console.log({ res: await res.json() }) - } - catch (err) { - console.log('could not add collaborator', err) - console.log({ inviteToken: config.github.inviteToken }) - } - return sendRedirect(event, `https://github.com/${repo}/invitations`) + return sendRedirect(event, await inviteCollaborator(createGitHub(config.github.inviteToken), repo, username)) }) diff --git a/modules/runtime/server/slides/[id].pdf.ts b/modules/runtime/server/slides/[id].pdf.ts index b13d9830..ca3c9a94 100644 --- a/modules/runtime/server/slides/[id].pdf.ts +++ b/modules/runtime/server/slides/[id].pdf.ts @@ -2,19 +2,11 @@ * Dev-only handler for `/slides/:id.pdf`. */ import { createError, defineEventHandler, getRouterParam, useRuntimeConfig } from 'nuxt/server' -import { $fetch } from 'ofetch' import { useAirspace } from '#server/utils/airspace' +import { createGitHub, downloadSlides } from '#server/utils/github' -interface GitHubReleaseAsset { - id: number - name: string -} -interface GitHubRelease { - assets: GitHubReleaseAsset[] -} - -const assetCache = new Map() +const assetCache = new Map() let knownIds: Set | null = null async function getKnownSlideIds (): Promise> { @@ -46,34 +38,11 @@ export default defineEventHandler(async event => { event.res.headers.set('content-type', 'application/pdf') const cached = assetCache.get(id) - if (cached) return new Uint8Array(cached) + if (cached) return cached - const release = await $fetch( - `https://api.github.com/repos/danielroe/slides/releases/tags/${id}`, - { - headers: { - 'Authorization': `token ${config.github.token}`, - 'Accept': 'application/vnd.github+json', - 'User-Agent': 'roe.dev-dev', - }, - }, - ) - - const assetId = release.assets.find(a => a.name.endsWith('.pdf'))?.id - if (!assetId) throw createError({ status: 404 }) - - const file = await $fetch( - `https://api.github.com/repos/danielroe/slides/releases/assets/${assetId}`, - { - responseType: 'arrayBuffer', - headers: { - 'Authorization': `token ${config.github.token}`, - 'Accept': 'application/octet-stream', - 'User-Agent': 'roe.dev-dev', - }, - }, - ) + const file = await downloadSlides(createGitHub(config.github.token), id) + if (!file) throw createError({ status: 404 }) assetCache.set(id, file) - return new Uint8Array(file) + return file }) diff --git a/modules/slides.ts b/modules/slides.ts index e514ac34..22ee78fc 100644 --- a/modules/slides.ts +++ b/modules/slides.ts @@ -1,7 +1,7 @@ import { addServerHandler, createResolver, defineNuxtModule, useNuxt, useRuntimeConfig } from 'nuxt/kit' -import { $fetch } from 'ofetch' import { put, head } from '@vercel/blob' +import { createGitHub, downloadSlides } from '../server/utils/github.ts' import { useBuildAirspace } from './shared/airspace.ts' export default defineNuxtModule({ @@ -23,12 +23,7 @@ export default defineNuxtModule({ return } - const $gh = $fetch.create({ - baseURL: 'https://api.github.com/repos/danielroe/slides/releases', - headers: { - Authorization: `token ${config.github.token}`, - }, - }) + const gh = createGitHub(config.github.token) const token = config.blobReadWriteToken @@ -39,15 +34,9 @@ export default defineNuxtModule({ const existingBlob = await head(pathname, { token }).catch(() => null) if (!existingBlob) { - const ghRelease = await $gh(`/tags/${release}`) - const id = ghRelease?.assets.find(a => a.name.endsWith('.pdf'))?.id + const file = await downloadSlides(gh, release) - if (!id) continue - - const file = await $gh(`/assets/${id}`, { - responseType: 'arrayBuffer', - headers: { Accept: 'application/octet-stream' }, - }) + if (!file) continue // Upload file to Vercel Blob const blob = await put(pathname, Buffer.from(file), { @@ -78,7 +67,3 @@ async function fetchSlideReleases (): Promise { .map(r => r.value.slides) .filter((s): s is string => Boolean(s)) } - -interface GitHubRelease { - assets: Array<{ id: number, name: string }> -} diff --git a/nuxt.config.ts b/nuxt.config.ts index 60927c5a..7a156137 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -167,7 +167,7 @@ export default defineNuxtConfig({ }, github: { // my GH ID - id: 'MDQ6VXNlcjI4NzA2Mzcy', + id: '28706372', // fetching GH repos in build + sponsors when deployed token: '', // token used to update GitHub status @@ -239,6 +239,7 @@ export default defineNuxtConfig({ } }), ), + inline: ['forges'], }, replace: { 'import.meta.test': isTest, diff --git a/package.json b/package.json index 1b909a2c..0a96df50 100644 --- a/package.json +++ b/package.json @@ -61,6 +61,7 @@ "comark-content": "0.4.1", "defu": "^6.1.7", "feed": "^6.0.0", + "forges": "https://pkg.pr.new/danielroe/forges@a5fbeaf", "gsap": "^3.15.0", "image-meta": "^0.3.0", "knitwork": "^1.3.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 07723db9..d0dad25c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -264,6 +264,9 @@ importers: feed: specifier: ^6.0.0 version: 6.0.0 + forges: + specifier: https://pkg.pr.new/danielroe/forges@a5fbeaf + version: https://pkg.pr.new/danielroe/forges@a5fbeaf gsap: specifier: ^3.15.0 version: 3.15.0 @@ -4593,6 +4596,11 @@ packages: resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} engines: {node: '>=14'} + forges@https://pkg.pr.new/danielroe/forges@a5fbeaf: + resolution: {integrity: sha512-82APilw1DEbm7ozCi2lzkcucXz260cwXB4Nr7a0PdXnKDNrBpMNiPtL6ecyhbB+BjQzBOg66keVBjgMUxsKiyg==, tarball: https://pkg.pr.new/danielroe/forges@a5fbeaf} + version: 0.1.0 + engines: {node: '>=22.18'} + formdata-polyfill@4.0.10: resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} engines: {node: '>=12.20.0'} @@ -12459,6 +12467,8 @@ snapshots: cross-spawn: 7.0.6 signal-exit: 4.1.0 + forges@https://pkg.pr.new/danielroe/forges@a5fbeaf: {} + formdata-polyfill@4.0.10: dependencies: fetch-blob: 3.2.0 diff --git a/server/api/admin/github-releases.get.ts b/server/api/admin/github-releases.get.ts index a85e0b7a..4cb4dbb6 100644 --- a/server/api/admin/github-releases.get.ts +++ b/server/api/admin/github-releases.get.ts @@ -2,16 +2,9 @@ * List releases from danielroe/slides for the talk-editor slides datalist. */ import { createError, defineEventHandler, useRuntimeConfig } from 'nuxt/server' -import { $fetch } from 'ofetch' import { requireAdminSession } from '../../utils/admin/oauth' - -interface GitHubRelease { - tag_name: string - name: string | null - published_at: string | null - draft: boolean -} +import { createGitHub, SLIDES_REPO } from '../../utils/github' export default defineEventHandler(async event => { await requireAdminSession(event) @@ -25,23 +18,14 @@ export default defineEventHandler(async event => { } try { - const releases = await $fetch( - 'https://api.github.com/repos/danielroe/slides/releases?per_page=100', - { - headers: { - 'Authorization': `token ${config.github.token}`, - 'User-Agent': 'roe.dev-admin', - 'Accept': 'application/vnd.github+json', - }, - }, - ) + const releases = await Array.fromAsync(createGitHub(config.github.token).releases.list(SLIDES_REPO, { perPage: 100 })) return releases - .filter(r => !r.draft) + .filter(r => !r.isDraft) .map(r => ({ - tag: r.tag_name, - name: r.name || r.tag_name, - publishedAt: r.published_at, + tag: r.tag, + name: r.name || r.tag, + publishedAt: r.publishedAt?.toISOString() ?? null, })) } catch (err) { diff --git a/server/api/update-location.post.ts b/server/api/update-location.post.ts index 647dee49..13acac02 100644 --- a/server/api/update-location.post.ts +++ b/server/api/update-location.post.ts @@ -59,13 +59,13 @@ export default defineEventHandler(async event => { ? String.fromCodePoint(...[...locationData.countryCode.toUpperCase()].map(char => char.charCodeAt(0) + 127397)) : '🌍' - await query(config.github.profileToken, ` - mutation { - changeUserStatus(input: { emoji: "${emoji}" }) { + await createGitHub(config.github.profileToken).graphql(` + mutation ($emoji: String!) { + changeUserStatus(input: { emoji: $emoji }) { status { emoji } } } - `) + `, { emoji }) } catch (error) { console.error('Failed to update GitHub status:', error) diff --git a/server/routes/auth/[...provider].get.ts b/server/routes/auth/[...provider].get.ts index 8d97d992..5efa9ba1 100644 --- a/server/routes/auth/[...provider].get.ts +++ b/server/routes/auth/[...provider].get.ts @@ -37,60 +37,36 @@ export default defineEventHandler(async event => { }) if (access_token) { + const gh = createGitHub(access_token) // Determine if user is a sponsor - const [viewer, ids] = await Promise.all([ - query(access_token, organisationQuery) - .then(r => { - const viewer = r?.viewer || {} - viewer.orgs - = viewer.organizations.edges.map((e: any) => e.node.id) || [] - return viewer - }) - .catch(err => { - console.error('viewer', err) - return {} - }), + const [viewer, orgs, ids] = await Promise.all([ + gh.users.me().catch(err => { + console.error('viewer', err) + return undefined + }), + fetchOrgIds(gh).catch(err => { + console.error('orgs', err) + return [] as string[] + }), getSponsors() .then(r => r.map(s => s.id)) .catch(err => { console.error('sponsor', err) - return [] + return [] as string[] }), ]) - console.info({ - sponsor: - // @ts-expect-error no typings for gh api response - ids.includes(viewer.id) || ids.some(i => viewer.orgs.includes(i)), - avatar: viewer.avatarUrl, - name: viewer.name, - }) + const session = { + sponsor: isSponsor(ids, viewer ? [viewer.id, ...orgs] : orgs), + avatar: viewer?.avatarUrl, + name: viewer?.name, + } + + console.info(session) // set custom JWT claim - await setUserSession(event, { - authenticated: true, - sponsor: - // @ts-expect-error no typings for gh api response - ids.includes(viewer.id) || ids.some(i => viewer.orgs.includes(i)), - avatar: viewer.avatarUrl, - name: viewer.name, - }) + await setUserSession(event, { authenticated: true, ...session }) } return sendRedirect(event, '/') }) - -const organisationQuery = `{ - viewer { - id - name - avatarUrl - organizations(first: 100) { - edges { - node { - id - } - } - } - } -}` diff --git a/server/utils/github.ts b/server/utils/github.ts index afec0240..9e6c43ba 100644 --- a/server/utils/github.ts +++ b/server/utils/github.ts @@ -1,16 +1,79 @@ -export const query = (accessToken: string, query: string) => - fetch('https://api.github.com/graphql', { - method: 'POST', - headers: { - 'authorization': `Bearer ${accessToken}`, - 'user-agent': 'roe (https://roe.dev, 0.1)', - }, - body: JSON.stringify({ query }), - }) - .then(r => r.json() as Promise<{ data: any }>) - .then(r => { - if ('errors' in r) { - throw r +import { repoRef } from 'forges' +import type { RepoRef } from 'forges' +import { github } from 'forges/github' +import type { GitHubProvider } from 'forges/github' + +export const SLIDES_REPO: RepoRef = { forge: 'github', instance: 'github.com', owner: 'danielroe', name: 'slides' } + +export interface Sponsor { + id: string + avatarUrl?: string + name?: string +} + +export function createGitHub (token: string): GitHubProvider { + return github({ + auth: { type: 'token', token }, + userAgent: 'roe (https://roe.dev, 0.1)', + }).create() +} + +export async function downloadSlides (gh: GitHubProvider, tag: string): Promise { + const release = await gh.releases.getByTag(SLIDES_REPO, tag) + const asset = release.assets?.find(a => a.name.endsWith('.pdf'))?.ref + if (!asset) return + return gh.releases.downloadAsset(asset, { as: 'binary' }) +} + +export async function fetchSponsors (gh: GitHubProvider, login: string): Promise { + const data = await gh.graphql(sponsorQuery, { login }) + return data.user.sponsors.nodes.map(node => ({ + id: String(node.databaseId), + avatarUrl: node.avatarUrl, + name: node.name ?? undefined, + })) +} + +export async function fetchOrgIds (gh: GitHubProvider): Promise { + const { data } = await gh.request>('GET', '/user/orgs', { query: { per_page: 100 } }) + return data.map(org => String(org.id)) +} + +export function isSponsor (sponsorIds: string[], accountIds: string[]): boolean { + return accountIds.some(id => sponsorIds.includes(id)) +} + +/** Gives `login` write access to `repo` and returns where to send them: the repository, or their pending invitation. */ +export async function inviteCollaborator (gh: GitHubProvider, repo: string, login: string): Promise { + const result = await gh.repos.addCollaborator(repoRef(gh, repo), login, 'write') + .catch(err => console.log('could not add collaborator', err)) + return `https://github.com/${repo}${result?.status === 'added' ? '' : '/invitations'}` +} + +interface SponsorsResponse { + user: { + sponsors: { + nodes: Array<{ databaseId: number, avatarUrl: string, name: string | null }> + } + } +} + +const sponsorQuery = /* graphql */ ` +query Sponsors($login: String!) { + user(login: $login) { + sponsors(first: 100) { + nodes { + ... on User { + databaseId + avatarUrl + name + } + ... on Organization { + databaseId + avatarUrl + name + } } - return r.data - }) + } + } +}` diff --git a/server/utils/sponsors.ts b/server/utils/sponsors.ts index 6ff5cf16..0358625b 100644 --- a/server/utils/sponsors.ts +++ b/server/utils/sponsors.ts @@ -1,12 +1,7 @@ import { useRuntimeConfig } from 'nuxt/server' -import { query } from './github' - -interface Sponsor { - id: string - avatarUrl?: string - name?: string -} +import { createGitHub, fetchSponsors } from './github' +import type { Sponsor } from './github' /** Reused across requests in development only. */ let devSponsors: Sponsor[] | undefined @@ -16,10 +11,7 @@ export async function getSponsors (): Promise { if (!token) return [] if (import.meta.dev && devSponsors) return devSponsors - const sponsors: Sponsor[] = await query( - token, - sponsorQuery, - ).then(r => r?.user.sponsors.edges.map((e: any) => e.node) || []) + const sponsors = await fetchSponsors(createGitHub(token), 'danielroe') // my ID sponsors.push({ id: useRuntimeConfig().github.id }) @@ -27,37 +19,15 @@ export async function getSponsors (): Promise { // NuxtLabs sponsors.unshift({ name: 'Vercel', - id: 'MDEyOk9yZ2FuaXphdGlvbjE0OTg1MDIw', + id: '14985020', avatarUrl: 'https://avatars.githubusercontent.com/u/14985020?v=4', }, { name: 'NuxtLabs', - id: 'MDEyOk9yZ2FuaXphdGlvbjYyMDE3NDAw', + id: '62017400', avatarUrl: 'https://avatars.githubusercontent.com/u/62017400?v=4', }) if (import.meta.dev) devSponsors = sponsors return sponsors } - -const sponsorQuery = /* graqhql */ ` -{ - user(login: "danielroe") { - sponsors(first: 100) { - edges { - node { - ... on User { - id - avatarUrl - name - } - ... on Organization { - id - avatarUrl - name - } - } - } - } - } -}` diff --git a/test/unit/bundle.spec.ts b/test/unit/bundle.spec.ts index 22491e3f..7a2d1b16 100644 --- a/test/unit/bundle.spec.ts +++ b/test/unit/bundle.spec.ts @@ -77,7 +77,7 @@ describe('project sizes', () => { stats.server = await analyzeSizes(['**/*.mjs', '!node_modules'], serverDir) expect .soft(roundToKilobytes(stats.server.totalBytes, 10)) - .toMatchInlineSnapshot(`"2080k"`) + .toMatchInlineSnapshot(`"2310k"`) const modules = await analyzeSizes('node_modules/**/*', serverDir) const portableModules = await analyzeSizes( diff --git a/test/unit/github.spec.ts b/test/unit/github.spec.ts new file mode 100644 index 00000000..ced842c5 --- /dev/null +++ b/test/unit/github.spec.ts @@ -0,0 +1,75 @@ +import { github } from 'forges/github' +import { fixtureFetch } from 'forges/testing' +import type { Fixture } from 'forges/testing' +import { describe, expect, it } from 'vitest' + +import { fetchOrgIds, fetchSponsors, inviteCollaborator, isSponsor } from '../../server/utils/github' + +const API = 'https://api.github.com' + +function createProvider (responses: Record) { + const { fetch } = fixtureFetch([], responses) + return github({ auth: { type: 'token', token: 'test' }, fetch }).create() +} + +const viewer = { status: 200, body: { login: 'octocat', id: 583231, node_id: 'MDQ6VXNlcjU4MzIzMQ==', type: 'User' } } + +function sponsors (...nodes: Array<{ id: string, databaseId: number }>): Fixture['response'] { + return { status: 200, body: { data: { user: { sponsors: { nodes: nodes.map(node => ({ ...node, avatarUrl: '', name: null })) } } } } } +} + +describe('sponsor check', () => { + it('recognises a sponsor by the id that users.me() returns', async () => { + const gh = createProvider({ + [`GET ${API}/user`]: viewer, + [`POST ${API}/graphql Sponsors`]: sponsors({ id: 'MDQ6VXNlcjU4MzIzMQ==', databaseId: 583231 }), + }) + + const ids = (await fetchSponsors(gh, 'danielroe')).map(s => s.id) + const me = await gh.users.me() + + expect(isSponsor(ids, [me.id])).toBe(true) + }) + + it('recognises a member of a sponsoring organisation', async () => { + const gh = createProvider({ + [`GET ${API}/user/orgs?per_page=100`]: { status: 200, body: [{ id: 62017400, login: 'nuxtlabs' }] }, + [`POST ${API}/graphql Sponsors`]: sponsors({ id: 'MDEyOk9yZ2FuaXphdGlvbjYyMDE3NDAw', databaseId: 62017400 }), + }) + + const ids = (await fetchSponsors(gh, 'danielroe')).map(s => s.id) + + expect(isSponsor(ids, await fetchOrgIds(gh))).toBe(true) + }) + + it('does not recognise an account that is not a sponsor', async () => { + const gh = createProvider({ + [`GET ${API}/user`]: viewer, + [`POST ${API}/graphql Sponsors`]: sponsors({ id: 'MDQ6VXNlcjE=', databaseId: 1 }), + }) + + const ids = (await fetchSponsors(gh, 'danielroe')).map(s => s.id) + const me = await gh.users.me() + + expect(isSponsor(ids, [me.id])).toBe(false) + }) +}) + +describe('repository invites', () => { + const url = `PUT ${API}/repos/danielroe/secret/collaborators/octocat` + + it('sends a new collaborator to their invitation', async () => { + const gh = createProvider({ [url]: { status: 201, body: { id: 1 } } }) + expect(await inviteCollaborator(gh, 'danielroe/secret', 'octocat')).toBe('https://github.com/danielroe/secret/invitations') + }) + + it('sends an account that already has access to the repository', async () => { + const gh = createProvider({ [url]: { status: 204 } }) + expect(await inviteCollaborator(gh, 'danielroe/secret', 'octocat')).toBe('https://github.com/danielroe/secret') + }) + + it('sends the account to the invitations page when the invite fails', async () => { + const gh = createProvider({ [url]: { status: 403, body: { message: 'Forbidden' } } }) + expect(await inviteCollaborator(gh, 'danielroe/secret', 'octocat')).toBe('https://github.com/danielroe/secret/invitations') + }) +}) -- 2.51.2