mirror of
https://github.com/nuxt/nuxt.git
synced 2024-11-25 07:05:11 +00:00
feat(nuxt): add nuxtMiddleware
route rule (#25841)
This commit is contained in:
parent
79ea75e72a
commit
f9fe282506
@ -1,7 +1,8 @@
|
|||||||
import type { MatcherExport, RouteMatcher } from 'radix3'
|
import type { MatcherExport, RouteMatcher } from 'radix3'
|
||||||
import { createMatcherFromExport } from 'radix3'
|
import { createMatcherFromExport, createRouter as createRadixRouter, toRouteMatcher } from 'radix3'
|
||||||
import { defu } from 'defu'
|
import { defu } from 'defu'
|
||||||
import { useAppConfig } from '../config'
|
import { useAppConfig } from '../config'
|
||||||
|
import { useRuntimeConfig } from '../nuxt'
|
||||||
// @ts-expect-error virtual file
|
// @ts-expect-error virtual file
|
||||||
import { appManifest as isAppManifestEnabled } from '#build/nuxt.config.mjs'
|
import { appManifest as isAppManifestEnabled } from '#build/nuxt.config.mjs'
|
||||||
// @ts-expect-error virtual file
|
// @ts-expect-error virtual file
|
||||||
@ -43,6 +44,12 @@ export function getAppManifest (): Promise<NuxtAppManifest> {
|
|||||||
|
|
||||||
/** @since 3.7.4 */
|
/** @since 3.7.4 */
|
||||||
export async function getRouteRules (url: string) {
|
export async function getRouteRules (url: string) {
|
||||||
|
if (import.meta.server) {
|
||||||
|
const _routeRulesMatcher = toRouteMatcher(
|
||||||
|
createRadixRouter({ routes: useRuntimeConfig().nitro!.routeRules })
|
||||||
|
)
|
||||||
|
return defu({} as Record<string, any>, ..._routeRulesMatcher.matchAll(url).reverse())
|
||||||
|
}
|
||||||
await getAppManifest()
|
await getAppManifest()
|
||||||
return defu({} as Record<string, any>, ...matcher.matchAll(url).reverse())
|
return defu({} as Record<string, any>, ...matcher.matchAll(url).reverse())
|
||||||
}
|
}
|
||||||
|
@ -3,11 +3,14 @@ import { computed, defineComponent, h, isReadonly, reactive } from 'vue'
|
|||||||
import { isEqual, joinURL, parseQuery, parseURL, stringifyParsedURL, stringifyQuery, withoutBase } from 'ufo'
|
import { isEqual, joinURL, parseQuery, parseURL, stringifyParsedURL, stringifyQuery, withoutBase } from 'ufo'
|
||||||
import { createError } from 'h3'
|
import { createError } from 'h3'
|
||||||
import { defineNuxtPlugin, useRuntimeConfig } from '../nuxt'
|
import { defineNuxtPlugin, useRuntimeConfig } from '../nuxt'
|
||||||
|
import { getRouteRules } from '../composables/manifest'
|
||||||
import { clearError, showError } from '../composables/error'
|
import { clearError, showError } from '../composables/error'
|
||||||
import { navigateTo } from '../composables/router'
|
import { navigateTo } from '../composables/router'
|
||||||
|
|
||||||
// @ts-expect-error virtual file
|
// @ts-expect-error virtual file
|
||||||
import { globalMiddleware } from '#build/middleware'
|
import { globalMiddleware } from '#build/middleware'
|
||||||
|
// @ts-expect-error virtual file
|
||||||
|
import { appManifest as isAppManifestEnabled } from '#build/nuxt.config.mjs'
|
||||||
|
|
||||||
interface Route {
|
interface Route {
|
||||||
/** Percentage encoded pathname section of the URL. */
|
/** Percentage encoded pathname section of the URL. */
|
||||||
@ -243,6 +246,23 @@ export default defineNuxtPlugin<{ route: Route, router: Router }>({
|
|||||||
if (import.meta.client || !nuxtApp.ssrContext?.islandContext) {
|
if (import.meta.client || !nuxtApp.ssrContext?.islandContext) {
|
||||||
const middlewareEntries = new Set<RouteGuard>([...globalMiddleware, ...nuxtApp._middleware.global])
|
const middlewareEntries = new Set<RouteGuard>([...globalMiddleware, ...nuxtApp._middleware.global])
|
||||||
|
|
||||||
|
if (isAppManifestEnabled) {
|
||||||
|
const routeRules = await nuxtApp.runWithContext(() => getRouteRules(to.path))
|
||||||
|
|
||||||
|
if (routeRules.nuxtMiddleware) {
|
||||||
|
for (const key in routeRules.nuxtMiddleware) {
|
||||||
|
const guard = nuxtApp._middleware.named[key] as RouteGuard | undefined
|
||||||
|
if (!guard) { return }
|
||||||
|
|
||||||
|
if (routeRules.nuxtMiddleware[key]) {
|
||||||
|
middlewareEntries.add(guard)
|
||||||
|
} else {
|
||||||
|
middlewareEntries.delete(guard)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for (const middleware of middlewareEntries) {
|
for (const middleware of middlewareEntries) {
|
||||||
const result = await nuxtApp.runWithContext(() => middleware(to, from))
|
const result = await nuxtApp.runWithContext(() => middleware(to, from))
|
||||||
if (import.meta.server) {
|
if (import.meta.server) {
|
||||||
|
@ -6,7 +6,7 @@ import { createRouter as createRadixRouter, exportMatcher, toRouteMatcher } from
|
|||||||
import { randomUUID } from 'uncrypto'
|
import { randomUUID } from 'uncrypto'
|
||||||
import { joinURL, withTrailingSlash } from 'ufo'
|
import { joinURL, withTrailingSlash } from 'ufo'
|
||||||
import { build, copyPublicAssets, createDevServer, createNitro, prepare, prerender, scanHandlers, writeTypes } from 'nitropack'
|
import { build, copyPublicAssets, createDevServer, createNitro, prepare, prerender, scanHandlers, writeTypes } from 'nitropack'
|
||||||
import type { Nitro, NitroConfig } from 'nitropack'
|
import type { Nitro, NitroConfig, NitroOptions } from 'nitropack'
|
||||||
import { findPath, logger, resolveIgnorePatterns, resolveNuxtModule, resolvePath } from '@nuxt/kit'
|
import { findPath, logger, resolveIgnorePatterns, resolveNuxtModule, resolvePath } from '@nuxt/kit'
|
||||||
import escapeRE from 'escape-string-regexp'
|
import escapeRE from 'escape-string-regexp'
|
||||||
import { defu } from 'defu'
|
import { defu } from 'defu'
|
||||||
@ -262,6 +262,25 @@ export async function initNitro (nuxt: Nuxt & { _nitro?: Nitro }) {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
nuxt.options.alias['#app-manifest'] = join(tempDir, `meta/${buildId}.json`)
|
||||||
|
|
||||||
|
nuxt.hook('nitro:config', (config) => {
|
||||||
|
const rules = config.routeRules
|
||||||
|
for (const rule in rules) {
|
||||||
|
if (!(rules[rule] as any).nuxtMiddleware) { continue }
|
||||||
|
const value = (rules[rule] as any).nuxtMiddleware
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
(rules[rule] as NitroOptions['routeRules']).nuxtMiddleware = { [value]: true }
|
||||||
|
} else if (Array.isArray(value)) {
|
||||||
|
const normalizedRules: Record<string, boolean> = {}
|
||||||
|
for (const middleware of value) {
|
||||||
|
normalizedRules[middleware] = true
|
||||||
|
}
|
||||||
|
(rules[rule] as NitroOptions['routeRules']).nuxtMiddleware = normalizedRules
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
nuxt.hook('nitro:init', (nitro) => {
|
nuxt.hook('nitro:init', (nitro) => {
|
||||||
nitro.hooks.hook('rollup:before', async (nitro) => {
|
nitro.hooks.hook('rollup:before', async (nitro) => {
|
||||||
const routeRules = {} as Record<string, any>
|
const routeRules = {} as Record<string, any>
|
||||||
@ -272,8 +291,12 @@ export async function initNitro (nuxt: Nuxt & { _nitro?: Nitro }) {
|
|||||||
const filteredRules = {} as Record<string, any>
|
const filteredRules = {} as Record<string, any>
|
||||||
for (const routeKey in _routeRules[key]) {
|
for (const routeKey in _routeRules[key]) {
|
||||||
const value = (_routeRules as any)[key][routeKey]
|
const value = (_routeRules as any)[key][routeKey]
|
||||||
if (['prerender', 'redirect'].includes(routeKey) && value) {
|
if (['prerender', 'redirect', 'nuxtMiddleware'].includes(routeKey) && value) {
|
||||||
filteredRules[routeKey] = routeKey === 'redirect' ? typeof value === 'string' ? value : value.to : value
|
if (routeKey === 'redirect') {
|
||||||
|
filteredRules[routeKey] = typeof value === 'string' ? value : value.to
|
||||||
|
} else {
|
||||||
|
filteredRules[routeKey] = value
|
||||||
|
}
|
||||||
hasRules = true
|
hasRules = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -244,6 +244,7 @@ declare module 'nitropack' {
|
|||||||
interface NitroRouteRules {
|
interface NitroRouteRules {
|
||||||
ssr?: boolean
|
ssr?: boolean
|
||||||
experimentalNoScripts?: boolean
|
experimentalNoScripts?: boolean
|
||||||
|
nuxtMiddleware?: Record<string, boolean>
|
||||||
}
|
}
|
||||||
interface NitroRuntimeHooks {
|
interface NitroRuntimeHooks {
|
||||||
'dev:ssr-logs': (ctx: { logs: LogObject[], path: string }) => void | Promise<void>
|
'dev:ssr-logs': (ctx: { logs: LogObject[], path: string }) => void | Promise<void>
|
||||||
|
@ -474,6 +474,11 @@ export default defineNuxtModule({
|
|||||||
' interface PageMeta {',
|
' interface PageMeta {',
|
||||||
' middleware?: MiddlewareKey | NavigationGuard | Array<MiddlewareKey | NavigationGuard>',
|
' middleware?: MiddlewareKey | NavigationGuard | Array<MiddlewareKey | NavigationGuard>',
|
||||||
' }',
|
' }',
|
||||||
|
'}',
|
||||||
|
'declare module \'nitropack\' {',
|
||||||
|
' interface NitroRouteConfig {',
|
||||||
|
' nuxtMiddleware?: MiddlewareKey | MiddlewareKey[] | Record<MiddlewareKey, boolean>',
|
||||||
|
' }',
|
||||||
'}'
|
'}'
|
||||||
].join('\n')
|
].join('\n')
|
||||||
}
|
}
|
||||||
|
@ -15,10 +15,13 @@ import type { PageMeta } from '../composables'
|
|||||||
|
|
||||||
import { toArray } from '../utils'
|
import { toArray } from '../utils'
|
||||||
import type { Plugin, RouteMiddleware } from '#app'
|
import type { Plugin, RouteMiddleware } from '#app'
|
||||||
|
import { getRouteRules } from '#app/composables/manifest'
|
||||||
import { defineNuxtPlugin, useRuntimeConfig } from '#app/nuxt'
|
import { defineNuxtPlugin, useRuntimeConfig } from '#app/nuxt'
|
||||||
import { clearError, showError, useError } from '#app/composables/error'
|
import { clearError, showError, useError } from '#app/composables/error'
|
||||||
import { navigateTo } from '#app/composables/router'
|
import { navigateTo } from '#app/composables/router'
|
||||||
|
|
||||||
|
// @ts-expect-error virtual file
|
||||||
|
import { appManifest as isAppManifestEnabled } from '#build/nuxt.config.mjs'
|
||||||
// @ts-expect-error virtual file
|
// @ts-expect-error virtual file
|
||||||
import _routes from '#build/routes'
|
import _routes from '#build/routes'
|
||||||
// @ts-expect-error virtual file
|
// @ts-expect-error virtual file
|
||||||
@ -173,6 +176,20 @@ const plugin: Plugin<{ router: Router }> = defineNuxtPlugin({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isAppManifestEnabled) {
|
||||||
|
const routeRules = await nuxtApp.runWithContext(() => getRouteRules(to.path))
|
||||||
|
|
||||||
|
if (routeRules.nuxtMiddleware) {
|
||||||
|
for (const key in routeRules.nuxtMiddleware) {
|
||||||
|
if (routeRules.nuxtMiddleware[key]) {
|
||||||
|
middlewareEntries.add(key)
|
||||||
|
} else {
|
||||||
|
middlewareEntries.delete(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for (const entry of middlewareEntries) {
|
for (const entry of middlewareEntries) {
|
||||||
const middleware = typeof entry === 'string' ? nuxtApp._middleware.named[entry] || await namedMiddleware[entry]?.().then((r: any) => r.default || r) : entry
|
const middleware = typeof entry === 'string' ? nuxtApp._middleware.named[entry] || await namedMiddleware[entry]?.().then((r: any) => r.default || r) : entry
|
||||||
|
|
||||||
|
@ -26,6 +26,7 @@ declare module 'nitropack' {
|
|||||||
interface NitroRouteRules {
|
interface NitroRouteRules {
|
||||||
ssr?: boolean
|
ssr?: boolean
|
||||||
experimentalNoScripts?: boolean
|
experimentalNoScripts?: boolean
|
||||||
|
nuxtMiddleware?: Record<string, boolean>
|
||||||
}
|
}
|
||||||
interface NitroRuntimeHooks {
|
interface NitroRuntimeHooks {
|
||||||
'dev:ssr-logs': (ctx: { logs: LogObject[], path: string }) => void | Promise<void>
|
'dev:ssr-logs': (ctx: { logs: LogObject[], path: string }) => void | Promise<void>
|
||||||
|
1
packages/nuxt/types.d.ts
vendored
1
packages/nuxt/types.d.ts
vendored
@ -26,6 +26,7 @@ declare module 'nitropack' {
|
|||||||
interface NitroRouteRules {
|
interface NitroRouteRules {
|
||||||
ssr?: boolean
|
ssr?: boolean
|
||||||
experimentalNoScripts?: boolean
|
experimentalNoScripts?: boolean
|
||||||
|
nuxtMiddleware?: Record<string, boolean>
|
||||||
}
|
}
|
||||||
interface NitroRuntimeHooks {
|
interface NitroRuntimeHooks {
|
||||||
'dev:ssr-logs': (ctx: { logs: LogObject[], path: string }) => void | Promise<void>
|
'dev:ssr-logs': (ctx: { logs: LogObject[], path: string }) => void | Promise<void>
|
||||||
|
@ -80,6 +80,11 @@ describe('route rules', () => {
|
|||||||
const html = await $fetch('/no-scripts')
|
const html = await $fetch('/no-scripts')
|
||||||
expect(html).not.toContain('<script')
|
expect(html).not.toContain('<script')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it.runIf(isTestingAppManifest)('should run middleware defined in routeRules config', async () => {
|
||||||
|
const html = await $fetch('/route-rules/middleware')
|
||||||
|
expect(html).toContain('Hello from routeRules!')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('modules', () => {
|
describe('modules', () => {
|
||||||
|
@ -19,7 +19,7 @@ describe.skipIf(process.env.SKIP_BUNDLE_SIZE === 'true' || process.env.ECOSYSTEM
|
|||||||
for (const outputDir of ['.output', '.output-inline']) {
|
for (const outputDir of ['.output', '.output-inline']) {
|
||||||
it('default client bundle size', async () => {
|
it('default client bundle size', async () => {
|
||||||
const clientStats = await analyzeSizes('**/*.js', join(rootDir, outputDir, 'public'))
|
const clientStats = await analyzeSizes('**/*.js', join(rootDir, outputDir, 'public'))
|
||||||
expect.soft(roundToKilobytes(clientStats.totalBytes)).toMatchInlineSnapshot('"105k"')
|
expect.soft(roundToKilobytes(clientStats.totalBytes)).toMatchInlineSnapshot('"106k"')
|
||||||
expect(clientStats.files.map(f => f.replace(/\..*\.js/, '.js'))).toMatchInlineSnapshot(`
|
expect(clientStats.files.map(f => f.replace(/\..*\.js/, '.js'))).toMatchInlineSnapshot(`
|
||||||
[
|
[
|
||||||
"_nuxt/entry.js",
|
"_nuxt/entry.js",
|
||||||
@ -32,7 +32,7 @@ describe.skipIf(process.env.SKIP_BUNDLE_SIZE === 'true' || process.env.ECOSYSTEM
|
|||||||
const serverDir = join(rootDir, '.output/server')
|
const serverDir = join(rootDir, '.output/server')
|
||||||
|
|
||||||
const serverStats = await analyzeSizes(['**/*.mjs', '!node_modules'], serverDir)
|
const serverStats = await analyzeSizes(['**/*.mjs', '!node_modules'], serverDir)
|
||||||
expect.soft(roundToKilobytes(serverStats.totalBytes)).toMatchInlineSnapshot('"205k"')
|
expect.soft(roundToKilobytes(serverStats.totalBytes)).toMatchInlineSnapshot('"206k"')
|
||||||
|
|
||||||
const modules = await analyzeSizes('node_modules/**/*', serverDir)
|
const modules = await analyzeSizes('node_modules/**/*', serverDir)
|
||||||
expect.soft(roundToKilobytes(modules.totalBytes)).toMatchInlineSnapshot('"1336k"')
|
expect.soft(roundToKilobytes(modules.totalBytes)).toMatchInlineSnapshot('"1336k"')
|
||||||
@ -72,7 +72,7 @@ describe.skipIf(process.env.SKIP_BUNDLE_SIZE === 'true' || process.env.ECOSYSTEM
|
|||||||
const serverDir = join(rootDir, '.output-inline/server')
|
const serverDir = join(rootDir, '.output-inline/server')
|
||||||
|
|
||||||
const serverStats = await analyzeSizes(['**/*.mjs', '!node_modules'], serverDir)
|
const serverStats = await analyzeSizes(['**/*.mjs', '!node_modules'], serverDir)
|
||||||
expect.soft(roundToKilobytes(serverStats.totalBytes)).toMatchInlineSnapshot('"524k"')
|
expect.soft(roundToKilobytes(serverStats.totalBytes)).toMatchInlineSnapshot('"525k"')
|
||||||
|
|
||||||
const modules = await analyzeSizes('node_modules/**/*', serverDir)
|
const modules = await analyzeSizes('node_modules/**/*', serverDir)
|
||||||
expect.soft(roundToKilobytes(modules.totalBytes)).toMatchInlineSnapshot('"77.8k"')
|
expect.soft(roundToKilobytes(modules.totalBytes)).toMatchInlineSnapshot('"77.8k"')
|
||||||
|
3
test/fixtures/basic/middleware/routeRulesMiddleware.ts
vendored
Normal file
3
test/fixtures/basic/middleware/routeRulesMiddleware.ts
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export default defineNuxtRouteMiddleware((to) => {
|
||||||
|
to.meta.hello = 'Hello from routeRules!'
|
||||||
|
})
|
1
test/fixtures/basic/nuxt.config.ts
vendored
1
test/fixtures/basic/nuxt.config.ts
vendored
@ -60,6 +60,7 @@ export default defineNuxtConfig({
|
|||||||
},
|
},
|
||||||
routeRules: {
|
routeRules: {
|
||||||
'/route-rules/spa': { ssr: false },
|
'/route-rules/spa': { ssr: false },
|
||||||
|
'/route-rules/middleware': { nuxtMiddleware: 'route-rules-middleware' },
|
||||||
'/hydration/spa-redirection/**': { ssr: false },
|
'/hydration/spa-redirection/**': { ssr: false },
|
||||||
'/no-scripts': { experimentalNoScripts: true }
|
'/no-scripts': { experimentalNoScripts: true }
|
||||||
},
|
},
|
||||||
|
5
test/fixtures/basic/pages/route-rules/middleware.vue
vendored
Normal file
5
test/fixtures/basic/pages/route-rules/middleware.vue
vendored
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div>Greeting: {{ $route.meta.hello }}</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
@ -19,29 +19,6 @@ import { useId } from '#app/composables/id'
|
|||||||
import { callOnce } from '#app/composables/once'
|
import { callOnce } from '#app/composables/once'
|
||||||
import { useLoadingIndicator } from '#app/composables/loading-indicator'
|
import { useLoadingIndicator } from '#app/composables/loading-indicator'
|
||||||
|
|
||||||
vi.mock('#app/compat/idle-callback', () => ({
|
|
||||||
requestIdleCallback: (cb: Function) => cb()
|
|
||||||
}))
|
|
||||||
|
|
||||||
const timestamp = Date.now()
|
|
||||||
registerEndpoint('/_nuxt/builds/latest.json', defineEventHandler(() => ({
|
|
||||||
id: 'override',
|
|
||||||
timestamp
|
|
||||||
})))
|
|
||||||
registerEndpoint('/_nuxt/builds/meta/override.json', defineEventHandler(() => ({
|
|
||||||
id: 'override',
|
|
||||||
timestamp,
|
|
||||||
matcher: {
|
|
||||||
static: {
|
|
||||||
'/': null,
|
|
||||||
'/pre': null,
|
|
||||||
'/pre/test': { redirect: true }
|
|
||||||
},
|
|
||||||
wildcard: { '/pre': { prerender: true } },
|
|
||||||
dynamic: {}
|
|
||||||
},
|
|
||||||
prerendered: ['/specific-prerendered']
|
|
||||||
})))
|
|
||||||
registerEndpoint('/api/test', defineEventHandler(event => ({
|
registerEndpoint('/api/test', defineEventHandler(event => ({
|
||||||
method: event.method,
|
method: event.method,
|
||||||
headers: Object.fromEntries(event.headers.entries())
|
headers: Object.fromEntries(event.headers.entries())
|
||||||
|
28
test/setup-runtime.ts
Normal file
28
test/setup-runtime.ts
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import { vi } from 'vitest'
|
||||||
|
import { defineEventHandler } from 'h3'
|
||||||
|
|
||||||
|
import { registerEndpoint } from '@nuxt/test-utils/runtime'
|
||||||
|
|
||||||
|
vi.mock('#app/compat/idle-callback', () => ({
|
||||||
|
requestIdleCallback: (cb: Function) => cb()
|
||||||
|
}))
|
||||||
|
|
||||||
|
const timestamp = Date.now()
|
||||||
|
registerEndpoint('/_nuxt/builds/latest.json', defineEventHandler(() => ({
|
||||||
|
id: 'override',
|
||||||
|
timestamp
|
||||||
|
})))
|
||||||
|
registerEndpoint('/_nuxt/builds/meta/override.json', defineEventHandler(() => ({
|
||||||
|
id: 'override',
|
||||||
|
timestamp,
|
||||||
|
matcher: {
|
||||||
|
static: {
|
||||||
|
'/': null,
|
||||||
|
'/pre': null,
|
||||||
|
'/pre/test': { redirect: true }
|
||||||
|
},
|
||||||
|
wildcard: { '/pre': { prerender: true } },
|
||||||
|
dynamic: {}
|
||||||
|
},
|
||||||
|
prerendered: ['/specific-prerendered']
|
||||||
|
})))
|
@ -7,6 +7,9 @@ export default defineVitestConfig({
|
|||||||
include: ['packages/nuxt/src/app']
|
include: ['packages/nuxt/src/app']
|
||||||
},
|
},
|
||||||
environment: 'nuxt',
|
environment: 'nuxt',
|
||||||
|
setupFiles: [
|
||||||
|
'./test/setup-runtime.ts'
|
||||||
|
],
|
||||||
environmentOptions: {
|
environmentOptions: {
|
||||||
nuxt: {
|
nuxt: {
|
||||||
overrides: {
|
overrides: {
|
||||||
|
Loading…
Reference in New Issue
Block a user