Skip to content

Commit

Permalink
Add forbidden and unauthorized APIs (#72785)
Browse files Browse the repository at this point in the history
  • Loading branch information
huozhi authored and wyattjoh committed Nov 28, 2024
1 parent a39cc14 commit b1c405a
Show file tree
Hide file tree
Showing 77 changed files with 997 additions and 57 deletions.
6 changes: 6 additions & 0 deletions crates/next-core/src/app_page_loader_tree.rs
Original file line number Diff line number Diff line change
Expand Up @@ -320,6 +320,8 @@ impl AppPageLoaderTreeBuilder {
template,
not_found,
metadata,
forbidden,
unauthorized,
route: _,
} = &modules;

Expand All @@ -343,6 +345,10 @@ impl AppPageLoaderTreeBuilder {
.await?;
self.write_modules_entry(AppDirModuleType::NotFound, *not_found)
.await?;
self.write_modules_entry(AppDirModuleType::Forbidden, *forbidden)
.await?;
self.write_modules_entry(AppDirModuleType::Unauthorized, *unauthorized)
.await?;
self.write_modules_entry(AppDirModuleType::Page, *page)
.await?;
self.write_modules_entry(AppDirModuleType::DefaultPage, *default)
Expand Down
29 changes: 25 additions & 4 deletions crates/next-core/src/app_structure.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@ pub struct AppDirModules {
#[serde(skip_serializing_if = "Option::is_none")]
pub template: Option<Vc<FileSystemPath>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub forbidden: Option<Vc<FileSystemPath>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub unauthorized: Option<Vc<FileSystemPath>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub not_found: Option<Vc<FileSystemPath>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub default: Option<Vc<FileSystemPath>>,
Expand All @@ -62,6 +66,8 @@ impl AppDirModules {
loading: self.loading,
template: self.template,
not_found: self.not_found,
forbidden: self.forbidden,
unauthorized: self.unauthorized,
default: None,
route: None,
metadata: self.metadata.clone(),
Expand Down Expand Up @@ -306,6 +312,8 @@ async fn get_directory_tree_internal(
"global-error" => modules.global_error = Some(file),
"loading" => modules.loading = Some(*file),
"template" => modules.template = Some(*file),
"forbidden" => modules.forbidden = Some(*file),
"unauthorized" => modules.unauthorized = Some(*file),
"not-found" => modules.not_found = Some(*file),
"default" => modules.default = Some(*file),
"route" => modules.route = Some(file),
Expand Down Expand Up @@ -857,10 +865,23 @@ fn directory_tree_to_loader_tree_internal(
// the path).
let is_root_layout = app_path.is_root() && modules.layout.is_some();

if (is_root_directory || is_root_layout) && modules.not_found.is_none() {
modules.not_found = Some(
get_next_package(app_dir).join("dist/client/components/not-found-error.js".into()),
);
if is_root_directory || is_root_layout {
if modules.not_found.is_none() {
modules.not_found = Some(
get_next_package(app_dir).join("dist/client/components/not-found-error.js".into()),
);
}
if modules.forbidden.is_none() {
modules.forbidden = Some(
get_next_package(app_dir).join("dist/client/components/forbidden-error.js".into()),
);
}
if modules.unauthorized.is_none() {
modules.unauthorized = Some(
get_next_package(app_dir)
.join("dist/client/components/unauthorized-error.js".into()),
);
}
}

let mut tree = AppPageLoaderTree {
Expand Down
4 changes: 4 additions & 0 deletions crates/next-core/src/base_loader_tree.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ pub enum AppDirModuleType {
Loading,
Template,
NotFound,
Forbidden,
Unauthorized,
GlobalError,
}

Expand All @@ -42,6 +44,8 @@ impl AppDirModuleType {
AppDirModuleType::Loading => "loading",
AppDirModuleType::Template => "template",
AppDirModuleType::NotFound => "not-found",
AppDirModuleType::Forbidden => "forbidden",
AppDirModuleType::Unauthorized => "unauthorized",
AppDirModuleType::GlobalError => "global-error",
}
}
Expand Down
5 changes: 5 additions & 0 deletions packages/next/src/build/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1260,6 +1260,9 @@ export default async function build(
)

const isAppDynamicIOEnabled = Boolean(config.experimental.dynamicIO)
const isAuthInterruptsEnabled = Boolean(
config.experimental.authInterrupts
)
const isAppPPREnabled = checkIsAppPPREnabled(config.experimental.ppr)

const routesManifestPath = path.join(distDir, ROUTES_MANIFEST)
Expand Down Expand Up @@ -1989,6 +1992,7 @@ export default async function build(
configFileName,
runtimeEnvConfig,
dynamicIO: isAppDynamicIOEnabled,
authInterrupts: isAuthInterruptsEnabled,
httpAgentOptions: config.httpAgentOptions,
locales: config.i18n?.locales,
defaultLocale: config.i18n?.defaultLocale,
Expand Down Expand Up @@ -2212,6 +2216,7 @@ export default async function build(
edgeInfo,
pageType,
dynamicIO: isAppDynamicIOEnabled,
authInterrupts: isAuthInterruptsEnabled,
cacheHandler: config.cacheHandler,
cacheHandlers: config.experimental.cacheHandlers,
isrFlushToDisk: ciEnvironment.hasNextSupport
Expand Down
6 changes: 6 additions & 0 deletions packages/next/src/build/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1214,6 +1214,7 @@ export async function buildAppStaticPaths({
page,
distDir,
dynamicIO,
authInterrupts,
configFileName,
segments,
isrFlushToDisk,
Expand All @@ -1230,6 +1231,7 @@ export async function buildAppStaticPaths({
dir: string
page: string
dynamicIO: boolean
authInterrupts: boolean
configFileName: string
segments: AppSegment[]
distDir: string
Expand Down Expand Up @@ -1312,6 +1314,7 @@ export async function buildAppStaticPaths({
experimental: {
after: false,
dynamicIO,
authInterrupts,
},
buildId,
},
Expand Down Expand Up @@ -1487,6 +1490,7 @@ export async function isPageStatic({
edgeInfo,
pageType,
dynamicIO,
authInterrupts,
originalAppPath,
isrFlushToDisk,
maxMemoryCacheSize,
Expand All @@ -1501,6 +1505,7 @@ export async function isPageStatic({
page: string
distDir: string
dynamicIO: boolean
authInterrupts: boolean
configFileName: string
runtimeEnvConfig: any
httpAgentOptions: NextConfigComplete['httpAgentOptions']
Expand Down Expand Up @@ -1642,6 +1647,7 @@ export async function isPageStatic({
dir,
page,
dynamicIO,
authInterrupts,
configFileName,
segments,
distDir,
Expand Down
50 changes: 37 additions & 13 deletions packages/next/src/build/webpack/loaders/next-app-loader/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,20 +54,30 @@ export type AppLoaderOptions = {
}
type AppLoader = webpack.LoaderDefinitionFunction<AppLoaderOptions>

const HTTP_ACCESS_FALLBACKS = {
'not-found': 'not-found',
forbidden: 'forbidden',
unauthorized: 'unauthorized',
} as const
const defaultHTTPAccessFallbackPaths = {
'not-found': 'next/dist/client/components/not-found-error',
forbidden: 'next/dist/client/components/forbidden-error',
unauthorized: 'next/dist/client/components/unauthorized-error',
} as const

const FILE_TYPES = {
layout: 'layout',
template: 'template',
error: 'error',
loading: 'loading',
'not-found': 'not-found',
'global-error': 'global-error',
...HTTP_ACCESS_FALLBACKS,
} as const

const GLOBAL_ERROR_FILE_TYPE = 'global-error'
const PAGE_SEGMENT = 'page$'
const PARALLEL_CHILDREN_SEGMENT = 'children$'

const defaultNotFoundPath = 'next/dist/client/components/not-found-error'
const defaultGlobalErrorPath = 'next/dist/client/components/error-boundary'
const defaultLayoutPath = 'next/dist/client/components/default-layout'

Expand Down Expand Up @@ -142,9 +152,6 @@ async function createTreeCodeFromPath(

const isDefaultNotFound = isAppBuiltinNotFoundPage(pagePath)
const appDirPrefix = isDefaultNotFound ? APP_DIR_ALIAS : splittedPath[0]
const hasRootNotFound = await resolver(
`${appDirPrefix}/${FILE_TYPES['not-found']}`
)
const pages: string[] = []

let rootLayout: string | undefined
Expand Down Expand Up @@ -302,18 +309,35 @@ async function createTreeCodeFromPath(
return false
}) as [ValueOf<typeof FILE_TYPES>, string][]

// Add default not found error as root not found if not present
const hasNotFoundFile = definedFilePaths.some(
([type]) => type === 'not-found'
// Add default access fallback as root fallback if not present
const existedConventionNames = new Set(
definedFilePaths.map(([type]) => type)
)
// If the first layer is a group route, we treat it as root layer
const isFirstLayerGroupRoute =
segments.length === 1 &&
subSegmentPath.filter((seg) => isGroupSegment(seg)).length === 1
if ((isRootLayer || isFirstLayerGroupRoute) && !hasNotFoundFile) {
// If you already have a root not found, don't insert default not-found to group routes root
if (!(hasRootNotFound && isFirstLayerGroupRoute)) {
definedFilePaths.push(['not-found', defaultNotFoundPath])

if (isRootLayer || isFirstLayerGroupRoute) {
const accessFallbackTypes = Object.keys(
defaultHTTPAccessFallbackPaths
) as (keyof typeof defaultHTTPAccessFallbackPaths)[]
for (const type of accessFallbackTypes) {
const hasRootFallbackFile = await resolver(
`${appDirPrefix}/${FILE_TYPES[type]}`
)
const hasLayerFallbackFile = existedConventionNames.has(type)

// If you already have a root access error fallback, don't insert default access error boundary to group routes root
if (
// Is treated as root layout and without boundary
!(hasRootFallbackFile && isFirstLayerGroupRoute) &&
// Does not have a fallback boundary file
!hasLayerFallbackFile
) {
const defaultFallbackPath = defaultHTTPAccessFallbackPaths[type]
definedFilePaths.push([type, defaultFallbackPath])
}
}
}

Expand Down Expand Up @@ -358,7 +382,7 @@ async function createTreeCodeFromPath(
if (isNotFoundRoute && normalizedParallelKey === 'children') {
const notFoundPath =
definedFilePaths.find(([type]) => type === 'not-found')?.[1] ??
defaultNotFoundPath
defaultHTTPAccessFallbackPaths['not-found']

const varName = `notFound${nestedCollectedDeclarations.length}`
nestedCollectedDeclarations.push([varName, notFoundPath])
Expand Down
2 changes: 2 additions & 0 deletions packages/next/src/build/webpack/plugins/define-env-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,8 @@ export function getDefineEnv({
// Internal only so untyped to avoid discovery
(config.experimental as any).internal_disableSyncDynamicAPIWarnings ??
false,
'process.env.__NEXT_EXPERIMENTAL_AUTH_INTERRUPTS':
!!config.experimental.authInterrupts,
...(isNodeOrEdgeCompilation
? {
// Fix bad-actors in the npm ecosystem (e.g. `node-formidable`)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import React from 'react'
import { HTTPAccessFallbackBoundary } from './http-access-fallback/error-boundary'

// TODO: error on using forbidden and unauthorized in root layout
export function bailOnRootNotFound() {
throw new Error('notFound() is not allowed to use in root layout')
}
Expand Down
10 changes: 10 additions & 0 deletions packages/next/src/client/components/forbidden-error.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { HTTPAccessErrorFallback } from './http-access-fallback/error-fallback'

export default function Forbidden() {
return (
<HTTPAccessErrorFallback
status={403}
message="This page could not be accessed."
/>
)
}
33 changes: 33 additions & 0 deletions packages/next/src/client/components/forbidden.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import {
HTTP_ERROR_FALLBACK_ERROR_CODE,
type HTTPAccessFallbackError,
} from './http-access-fallback/http-access-fallback'

// TODO: Add `forbidden` docs
/**
* @experimental
* This function allows you to render the [forbidden.js file](https://nextjs.org/docs/app/api-reference/file-conventions/forbidden)
* within a route segment as well as inject a tag.
*
* `forbidden()` can be used in
* [Server Components](https://nextjs.org/docs/app/building-your-application/rendering/server-components),
* [Route Handlers](https://nextjs.org/docs/app/building-your-application/routing/route-handlers), and
* [Server Actions](https://nextjs.org/docs/app/building-your-application/data-fetching/server-actions-and-mutations).
*
* Read more: [Next.js Docs: `forbidden`](https://nextjs.org/docs/app/api-reference/functions/forbidden)
*/
export function forbidden(): never {
if (!process.env.__NEXT_EXPERIMENTAL_AUTH_INTERRUPTS) {
throw new Error(
`\`forbidden()\` is experimental and only allowed to be enabled when \`experimental.authInterrupts\` is enabled.`
)
}

// eslint-disable-next-line no-throw-literal
const error = new Error(
HTTP_ERROR_FALLBACK_ERROR_CODE
) as HTTPAccessFallbackError
;(error as HTTPAccessFallbackError).digest =
`${HTTP_ERROR_FALLBACK_ERROR_CODE};403`
throw error
}
Loading

0 comments on commit b1c405a

Please sign in to comment.