diff --git a/modules/runtime/server/cache.ts b/modules/runtime/server/cache.ts index 5359f75ea4..68c482ed31 100644 --- a/modules/runtime/server/cache.ts +++ b/modules/runtime/server/cache.ts @@ -23,6 +23,7 @@ const FIXTURE_PATHS = { packument: 'npm-registry:packuments', search: 'npm-registry:search', org: 'npm-registry:orgs', + orgUsers: 'npm-registry:org-users', downloads: 'npm-api:downloads', user: 'users', esmHeaders: 'esm-sh:headers', @@ -56,6 +57,7 @@ function getFixturePath(type: FixtureType, name: string): string { filename = `${name.replace(/:/g, '-')}.json` break case 'org': + case 'orgUsers': case 'user': filename = `${name}.json` break @@ -461,6 +463,12 @@ function matchUrlToFixture(url: string): FixtureMatchWithVersion | null { return { type: 'org', name: orgMatch[1] } } + // Org users (distinguishes real orgs from user accounts for /org/ redirects) + const orgUsersMatch = pathname.match(/^\/-\/org\/([^/]+)\/user$/) + if (orgUsersMatch?.[1]) { + return { type: 'orgUsers', name: orgUsersMatch[1] } + } + // Packument - handle both full packument and version manifest requests let packagePath = decodeURIComponent(pathname.slice(1)) if (packagePath && !packagePath.startsWith('-/')) { @@ -621,6 +629,15 @@ async function fetchFromFixtures( }) } + // For org users without fixtures, return 404 (name is neither an org nor a user) + if (match.type === 'orgUsers') { + throw createError({ + statusCode: 404, + statusMessage: 'Not found', + message: `No fixture for org user: ${match.name}`, + }) + } + // For packuments without fixtures, return a stub packument // This allows tests to work without needing fixtures for every dependency if (match.type === 'packument') { diff --git a/server/middleware/canonical-redirects.global.ts b/server/middleware/canonical-redirects.global.ts index 738612c2a7..3565623551 100644 --- a/server/middleware/canonical-redirects.global.ts +++ b/server/middleware/canonical-redirects.global.ts @@ -1,3 +1,5 @@ +import { NPM_REGISTRY } from '#shared/utils/constants' + /** * Redirect legacy/shorthand URLs to canonical paths. * @@ -5,6 +7,7 @@ * - /@org/pkg or /pkg → /package/@org/pkg or /package/pkg * - /@org/pkg/v/ver or /pkg@ver → /package/@org/pkg/v/ver or /package/pkg/v/ver * - /@org → /org/org + * - /org/ → /~ (when is a user account, not an org) * * Handled via route aliases (not here): * - /package/code/* → /package-code/* @@ -74,6 +77,39 @@ export default defineEventHandler(async event => { return } + // /org/ → /~ if is a user, not an org (matches npmjs.com). + // NOTE: this must run before the `pages` allowlist check below, since '/org' + // is allowlisted (to protect bare `/org` from the generic /pkg redirect). + // Only exact single-segment /org/ paths are checked here; bare `/org` + // and deeper paths still fall through to the allowlist. + // Detection uses /-/org//user ({} = real org, non-empty = user, + // 404 = neither) — /-/org//package returns 200 for users too, so it + // cannot be used for detection. + const orgPageMatch = path.match(/^\/org\/(?[^/]+)$/) + const orgPageName = orgPageMatch?.groups?.name + if (orgPageName) { + const name = orgPageName.toLowerCase() + try { + const data = await $fetch>( + `${NPM_REGISTRY}/-/org/${encodeURIComponent(name)}/user`, + // Bounded lookup: a stalled registry connection must reject (and hit + // the fail-open catch below) instead of hanging the page render. + // retry: 0 keeps the deadline covering the complete lookup. + { timeout: 5000, retry: 0 }, + ) + if (Object.keys(data).length > 0) { + setHeader(event, 'cache-control', cacheControl) + return sendRedirect(event, `/~${name}` + (query ? '?' + query : ''), 301) + } + // {} means real org — fall through, let the org page render as normal + } catch { + // 404 (name doesn't exist) or any other error — fall through, + // let the org page's own 404 handling take over. Do not throw + // here; a failure in this check must never block rendering the + // org page itself. + } + } + if (pages.some(page => path === page || path.startsWith(page + '/'))) { return } diff --git a/test/e2e/url-compatibility.spec.ts b/test/e2e/url-compatibility.spec.ts index bfb0cd3a26..1fd09274e4 100644 --- a/test/e2e/url-compatibility.spec.ts +++ b/test/e2e/url-compatibility.spec.ts @@ -135,6 +135,11 @@ test.describe('npmjs.com URL Compatibility', () => { // Should show 404 error page await expect(page.locator('h1')).toContainText('Organization not found') }) + + test('/org/ redirects to /~', async ({ page, goto }) => { + await goto('/org/qwerzl', { waitUntil: 'domcontentloaded' }) + await expect(page).toHaveURL(/\/~qwerzl$/) + }) }) test.describe('npmjs.com activeTab=versions Compatibility', () => { diff --git a/test/fixtures/mock-routes.cjs b/test/fixtures/mock-routes.cjs index 775372a268..85cb38da28 100644 --- a/test/fixtures/mock-routes.cjs +++ b/test/fixtures/mock-routes.cjs @@ -12,7 +12,7 @@ 'use strict' const { existsSync, readFileSync } = require('node:fs') -const { join } = require('node:path') +const { join, resolve, sep } = require('node:path') const FIXTURES_DIR = join(__dirname) @@ -21,7 +21,13 @@ const FIXTURES_DIR = join(__dirname) * @returns {unknown | null} */ function readFixture(relativePath) { - const fullPath = join(FIXTURES_DIR, relativePath) + // Containment check: never serve files outside FIXTURES_DIR, even if a + // URL-derived segment contains `..` or (on Windows, after URL decoding) + // a backslash path separator. + const fullPath = resolve(FIXTURES_DIR, relativePath) + if (fullPath !== FIXTURES_DIR && !fullPath.startsWith(FIXTURES_DIR + sep)) { + return null + } if (!existsSync(fullPath)) { return null } @@ -134,6 +140,16 @@ function matchNpmRegistry(urlString) { return json({ error: 'Not found' }, 404) } + // Org users (distinguishes real orgs from user accounts for /org/ redirects) + const orgUserMatch = pathname.match(/^\/-\/org\/([^/]+)\/user$/) + if (orgUserMatch && orgUserMatch[1]) { + const fixture = readFixture(`npm-registry/org-users/${orgUserMatch[1]}.json`) + if (fixture) { + return json(fixture) + } + return json({ error: 'Not found' }, 404) + } + // Attestations endpoint - return empty attestations if (pathname.startsWith('/-/npm/v1/attestations/')) { return json({ attestations: [] }) diff --git a/test/fixtures/npm-registry/org-users/nuxt.json b/test/fixtures/npm-registry/org-users/nuxt.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/test/fixtures/npm-registry/org-users/nuxt.json @@ -0,0 +1 @@ +{} diff --git a/test/fixtures/npm-registry/org-users/qwerzl.json b/test/fixtures/npm-registry/org-users/qwerzl.json new file mode 100644 index 0000000000..31bede77c5 --- /dev/null +++ b/test/fixtures/npm-registry/org-users/qwerzl.json @@ -0,0 +1 @@ +{ "qwerzl": "owner" } diff --git a/test/fixtures/npm-registry/org-users/testorg.json b/test/fixtures/npm-registry/org-users/testorg.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/test/fixtures/npm-registry/org-users/testorg.json @@ -0,0 +1 @@ +{} diff --git a/test/unit/server/middleware/canonical-redirects.spec.ts b/test/unit/server/middleware/canonical-redirects.spec.ts new file mode 100644 index 0000000000..796ba4bdb4 --- /dev/null +++ b/test/unit/server/middleware/canonical-redirects.spec.ts @@ -0,0 +1,107 @@ +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' +import type { H3Event } from 'h3' + +const fetchMock = vi.fn() +const setHeaderMock = vi.fn() +const sendRedirectMock = vi.fn() + +vi.stubGlobal('$fetch', fetchMock) +vi.stubGlobal('setHeader', setHeaderMock) +vi.stubGlobal('sendRedirect', sendRedirectMock) +vi.stubGlobal('defineEventHandler', (fn: Function) => fn) +vi.stubGlobal('getRouteRules', () => ({})) + +const handler = (await import('#server/middleware/canonical-redirects.global')).default + +function makeEvent(path: string): H3Event { + return { path } as H3Event +} + +afterAll(() => { + vi.unstubAllGlobals() +}) + +describe('canonical-redirects middleware (/org/ → /~)', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('does not redirect for a real org (/-/org//user returns {})', async () => { + fetchMock.mockResolvedValue({}) + + await handler(makeEvent('/org/nuxt')) + + expect(fetchMock).toHaveBeenCalledWith('https://registry.npmjs.org/-/org/nuxt/user', { + timeout: 5000, + retry: 0, + }) + expect(sendRedirectMock).not.toHaveBeenCalled() + }) + + it('redirects with 301 to /~ for a user account (/-/org//user returns non-empty)', async () => { + fetchMock.mockResolvedValue({ qwerzl: 'owner' }) + + await handler(makeEvent('/org/qwerzl')) + + expect(fetchMock).toHaveBeenCalledWith('https://registry.npmjs.org/-/org/qwerzl/user', { + timeout: 5000, + retry: 0, + }) + expect(setHeaderMock).toHaveBeenCalledWith( + expect.anything(), + 'cache-control', + expect.any(String), + ) + expect(sendRedirectMock).toHaveBeenCalledWith(expect.anything(), '/~qwerzl', 301) + }) + + it('preserves the query string when redirecting a user account', async () => { + fetchMock.mockResolvedValue({ QWERZL: 'owner' }) + + await handler(makeEvent('/org/QWERZL?tab=members')) + + // name is lowercased, matching npmjs.com behavior + expect(sendRedirectMock).toHaveBeenCalledWith(expect.anything(), '/~qwerzl?tab=members', 301) + }) + + it('does not redirect for a nonexistent name (/-/org//user 404s), letting the org page 404', async () => { + fetchMock.mockRejectedValue({ statusCode: 404, message: 'Not found' }) + + await expect(handler(makeEvent('/org/nonexistent-org-12345'))).resolves.toBeUndefined() + + expect(fetchMock).toHaveBeenCalledWith( + 'https://registry.npmjs.org/-/org/nonexistent-org-12345/user', + { timeout: 5000, retry: 0 }, + ) + expect(sendRedirectMock).not.toHaveBeenCalled() + }) + + it('fails open when the user check itself errors (network error, 500, ...)', async () => { + fetchMock.mockRejectedValue(new Error('network failure')) + + await expect(handler(makeEvent('/org/nuxt'))).resolves.toBeUndefined() + + expect(sendRedirectMock).not.toHaveBeenCalled() + }) + + it('fails open on registry timeout (bounded lookup, no retries)', async () => { + const timeoutError = new Error('Request aborted due to timeout') + timeoutError.name = 'TimeoutError' + fetchMock.mockRejectedValue(timeoutError) + + await expect(handler(makeEvent('/org/nuxt'))).resolves.toBeUndefined() + + expect(fetchMock).toHaveBeenCalledWith( + 'https://registry.npmjs.org/-/org/nuxt/user', + expect.objectContaining({ timeout: 5000, retry: 0 }), + ) + expect(sendRedirectMock).not.toHaveBeenCalled() + }) + + it('does not run the user check for non-/org/ paths', async () => { + await handler(makeEvent('/package/vue')) + + expect(fetchMock).not.toHaveBeenCalled() + expect(sendRedirectMock).not.toHaveBeenCalled() + }) +})