fix(SW-663): Fixed leaking of live preview hash and removing preview pages
This commit is contained in:
@@ -0,0 +1,19 @@
|
|||||||
|
import { isPreview, setPreviewData } from "@/lib/previewContext"
|
||||||
|
|
||||||
|
import InitLivePreview from "@/components/LivePreview"
|
||||||
|
|
||||||
|
import { PageArgs, UIDParams } from "@/types/params"
|
||||||
|
|
||||||
|
export default function PreviewPage({
|
||||||
|
searchParams,
|
||||||
|
params,
|
||||||
|
}: PageArgs<UIDParams, URLSearchParams>) {
|
||||||
|
const shouldInitializePreview = searchParams.isPreview === "true"
|
||||||
|
const isInitialized = isPreview()
|
||||||
|
|
||||||
|
if (searchParams.live_preview) {
|
||||||
|
setPreviewData({ hash: searchParams.live_preview, uid: params.uid })
|
||||||
|
}
|
||||||
|
|
||||||
|
return shouldInitializePreview && !isInitialized ? <InitLivePreview /> : null
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import React from "react"
|
||||||
|
|
||||||
import styles from "./layout.module.css"
|
import styles from "./layout.module.css"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -9,15 +11,18 @@ import {
|
|||||||
|
|
||||||
export default function ContentTypeLayout({
|
export default function ContentTypeLayout({
|
||||||
breadcrumbs,
|
breadcrumbs,
|
||||||
|
preview,
|
||||||
children,
|
children,
|
||||||
}: React.PropsWithChildren<
|
}: React.PropsWithChildren<
|
||||||
LayoutArgs<LangParams & ContentTypeParams & UIDParams> & {
|
LayoutArgs<LangParams & ContentTypeParams & UIDParams> & {
|
||||||
breadcrumbs: React.ReactNode
|
breadcrumbs: React.ReactNode
|
||||||
|
preview: React.ReactNode
|
||||||
}
|
}
|
||||||
>) {
|
>) {
|
||||||
return (
|
return (
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
<section className={styles.layout}>
|
<section className={styles.layout}>
|
||||||
|
{preview}
|
||||||
{breadcrumbs}
|
{breadcrumbs}
|
||||||
{children}
|
{children}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
export default function Error({ error }: { error: Error }) {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<h2>Something went wrong!</h2>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
import "@/app/globals.css"
|
|
||||||
import "@scandic-hotels/design-system/style.css"
|
|
||||||
|
|
||||||
import TrpcProvider from "@/lib/trpc/Provider"
|
|
||||||
|
|
||||||
import InitLivePreview from "@/components/LivePreview"
|
|
||||||
import { getIntl } from "@/i18n"
|
|
||||||
import ServerIntlProvider from "@/i18n/Provider"
|
|
||||||
import { setLang } from "@/i18n/serverContext"
|
|
||||||
|
|
||||||
import type { LangParams, LayoutArgs } from "@/types/params"
|
|
||||||
|
|
||||||
export default async function RootLayout({
|
|
||||||
children,
|
|
||||||
params,
|
|
||||||
}: React.PropsWithChildren<LayoutArgs<LangParams>>) {
|
|
||||||
setLang(params.lang)
|
|
||||||
const { defaultLocale, locale, messages } = await getIntl()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<html lang={params.lang}>
|
|
||||||
<body>
|
|
||||||
<InitLivePreview />
|
|
||||||
<ServerIntlProvider intl={{ defaultLocale, locale, messages }}>
|
|
||||||
<TrpcProvider>{children}</TrpcProvider>
|
|
||||||
</ServerIntlProvider>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
export default function NotFound() {
|
|
||||||
return (
|
|
||||||
<main>
|
|
||||||
<h1>Not found</h1>
|
|
||||||
<p>Could not find requested resource</p>
|
|
||||||
</main>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
import { ContentstackLivePreview } from "@contentstack/live-preview-utils"
|
|
||||||
import { notFound } from "next/navigation"
|
|
||||||
|
|
||||||
import HotelPage from "@/components/ContentType/HotelPage"
|
|
||||||
import LoyaltyPage from "@/components/ContentType/LoyaltyPage"
|
|
||||||
import CollectionPage from "@/components/ContentType/StaticPages/CollectionPage"
|
|
||||||
import ContentPage from "@/components/ContentType/StaticPages/ContentPage"
|
|
||||||
import { setLang } from "@/i18n/serverContext"
|
|
||||||
|
|
||||||
import type {
|
|
||||||
ContentTypeParams,
|
|
||||||
LangParams,
|
|
||||||
PageArgs,
|
|
||||||
UIDParams,
|
|
||||||
} from "@/types/params"
|
|
||||||
|
|
||||||
export default async function PreviewPage({
|
|
||||||
params,
|
|
||||||
searchParams,
|
|
||||||
}: PageArgs<LangParams & ContentTypeParams & UIDParams, {}>) {
|
|
||||||
setLang(params.lang)
|
|
||||||
|
|
||||||
try {
|
|
||||||
ContentstackLivePreview.setConfigFromParams(searchParams)
|
|
||||||
|
|
||||||
if (!searchParams.live_preview) {
|
|
||||||
return notFound()
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (params.contentType) {
|
|
||||||
case "content-page":
|
|
||||||
return <ContentPage />
|
|
||||||
case "loyalty-page":
|
|
||||||
return <LoyaltyPage />
|
|
||||||
case "collection-page":
|
|
||||||
return <CollectionPage />
|
|
||||||
case "hotel-page":
|
|
||||||
return <HotelPage />
|
|
||||||
default:
|
|
||||||
console.log({ PREVIEW: params })
|
|
||||||
const type = params.contentType
|
|
||||||
console.error(`Unsupported content type given: ${type}`)
|
|
||||||
notFound()
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// TODO: throw 500
|
|
||||||
console.error("Error in preview page")
|
|
||||||
console.error(error)
|
|
||||||
throw new Error("Something went wrong")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
export default function Error({ error }: { error: Error }) {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<h2>Something went wrong!</h2>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
import Footer from "@/components/Current/Footer"
|
|
||||||
import LangPopup from "@/components/Current/LangPopup"
|
|
||||||
import InitLivePreview from "@/components/LivePreview"
|
|
||||||
import SkipToMainContent from "@/components/SkipToMainContent"
|
|
||||||
import { setLang } from "@/i18n/serverContext"
|
|
||||||
|
|
||||||
import type { Metadata } from "next"
|
|
||||||
|
|
||||||
import type { LangParams, LayoutArgs } from "@/types/params"
|
|
||||||
|
|
||||||
export const fetchCache = "default-no-store"
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
|
||||||
description: "New web",
|
|
||||||
title: "Scandic Hotels",
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function RootLayout({
|
|
||||||
children,
|
|
||||||
params,
|
|
||||||
}: React.PropsWithChildren<LayoutArgs<LangParams>>) {
|
|
||||||
setLang(params.lang)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<html lang={params.lang}>
|
|
||||||
<head>
|
|
||||||
{/* eslint-disable-next-line @next/next/no-css-tags */}
|
|
||||||
<link rel="stylesheet" href="/_static/css/core.css" />
|
|
||||||
{/* eslint-disable-next-line @next/next/no-css-tags */}
|
|
||||||
<link rel="stylesheet" href="/_static/css/scandic.css" />
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<InitLivePreview />
|
|
||||||
<LangPopup />
|
|
||||||
<SkipToMainContent />
|
|
||||||
{children}
|
|
||||||
<Footer />
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
export default function NotFound() {
|
|
||||||
return (
|
|
||||||
<main>
|
|
||||||
<h1>Not found</h1>
|
|
||||||
<p>Could not find requested resource</p>
|
|
||||||
</main>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
import { ContentstackLivePreview } from "@contentstack/live-preview-utils"
|
|
||||||
|
|
||||||
import { previewRequest } from "@/lib/graphql/previewRequest"
|
|
||||||
import { GetCurrentBlockPage } from "@/lib/graphql/Query/Current/CurrentBlockPage.graphql"
|
|
||||||
|
|
||||||
import ContentPage from "@/components/Current/ContentPage"
|
|
||||||
import LoadingSpinner from "@/components/Current/LoadingSpinner"
|
|
||||||
import { setLang } from "@/i18n/serverContext"
|
|
||||||
|
|
||||||
import type { PageArgs, PreviewParams } from "@/types/params"
|
|
||||||
import { LangParams } from "@/types/params"
|
|
||||||
import type { GetCurrentBlockPageData } from "@/types/requests/currentBlockPage"
|
|
||||||
|
|
||||||
export default async function CurrentPreviewPage({
|
|
||||||
params,
|
|
||||||
searchParams,
|
|
||||||
}: PageArgs<LangParams, PreviewParams>) {
|
|
||||||
setLang(params.lang)
|
|
||||||
|
|
||||||
try {
|
|
||||||
ContentstackLivePreview.setConfigFromParams(searchParams)
|
|
||||||
|
|
||||||
if (!searchParams.uri || !searchParams.live_preview) {
|
|
||||||
return <LoadingSpinner />
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await previewRequest<GetCurrentBlockPageData>(
|
|
||||||
GetCurrentBlockPage,
|
|
||||||
{ locale: params.lang, url: searchParams.uri }
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!response.data?.all_current_blocks_page?.total) {
|
|
||||||
console.log("#### DATA ####")
|
|
||||||
console.log(response.data)
|
|
||||||
console.log("SearchParams URI: ", searchParams.uri)
|
|
||||||
throw new Error("Not found")
|
|
||||||
}
|
|
||||||
|
|
||||||
return <ContentPage data={response.data} />
|
|
||||||
} catch (error) {
|
|
||||||
// TODO: throw 500
|
|
||||||
console.error("Error in current preview page")
|
|
||||||
console.error(error)
|
|
||||||
throw new Error("Something went wrong")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -5,9 +5,7 @@ import { useEffect } from "react"
|
|||||||
|
|
||||||
export default function InitLivePreview() {
|
export default function InitLivePreview() {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!ContentstackLivePreview.livePreview) {
|
ContentstackLivePreview.init()
|
||||||
ContentstackLivePreview.init()
|
|
||||||
}
|
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
return null
|
return null
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import "server-only"
|
import "server-only"
|
||||||
|
|
||||||
import { ContentstackLivePreview } from "@contentstack/live-preview-utils"
|
// import { ContentstackLivePreview } from "@contentstack/live-preview-utils"
|
||||||
import { ClientError, GraphQLClient } from "graphql-request"
|
import { ClientError, GraphQLClient } from "graphql-request"
|
||||||
|
|
||||||
import { Lang } from "@/constants/languages"
|
import { Lang } from "@/constants/languages"
|
||||||
@@ -17,47 +17,41 @@ export async function request<T>(
|
|||||||
params?: RequestInit
|
params?: RequestInit
|
||||||
): Promise<Data<T>> {
|
): Promise<Data<T>> {
|
||||||
try {
|
try {
|
||||||
const previewHash = ContentstackLivePreview.hash
|
|
||||||
client.setHeaders({
|
client.setHeaders({
|
||||||
access_token: env.CMS_ACCESS_TOKEN,
|
access_token: env.CMS_ACCESS_TOKEN,
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
...params?.headers,
|
...params?.headers,
|
||||||
})
|
})
|
||||||
|
|
||||||
if (previewHash) {
|
client.requestConfig.cache = params?.cache
|
||||||
client.setHeader("preview_token", env.CMS_PREVIEW_TOKEN)
|
client.requestConfig.next = params?.next
|
||||||
client.setHeader("live_preview", previewHash)
|
|
||||||
} else {
|
|
||||||
client.requestConfig.cache = params?.cache
|
|
||||||
client.requestConfig.next = params?.next
|
|
||||||
|
|
||||||
if (env.PRINT_QUERY) {
|
if (env.PRINT_QUERY) {
|
||||||
const print = (await import("graphql/language/printer")).print
|
const print = (await import("graphql/language/printer")).print
|
||||||
const rawResponse = await client.rawRequest<T>(
|
const rawResponse = await client.rawRequest<T>(
|
||||||
print(query as DocumentNode),
|
print(query as DocumentNode),
|
||||||
variables,
|
variables,
|
||||||
{
|
{
|
||||||
access_token: env.CMS_ACCESS_TOKEN,
|
access_token: env.CMS_ACCESS_TOKEN,
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* TODO: Send to Monitoring (Logging and Metrics)
|
|
||||||
*/
|
|
||||||
console.log({
|
|
||||||
complexityLimit: rawResponse.headers.get("x-query-complexity"),
|
|
||||||
})
|
|
||||||
console.log({
|
|
||||||
referenceDepth: rawResponse.headers.get("x-reference-depth"),
|
|
||||||
})
|
|
||||||
console.log({
|
|
||||||
resolverCost: rawResponse.headers.get("x-resolver-cost"),
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
data: rawResponse.data,
|
|
||||||
}
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TODO: Send to Monitoring (Logging and Metrics)
|
||||||
|
*/
|
||||||
|
console.log({
|
||||||
|
complexityLimit: rawResponse.headers.get("x-query-complexity"),
|
||||||
|
})
|
||||||
|
console.log({
|
||||||
|
referenceDepth: rawResponse.headers.get("x-reference-depth"),
|
||||||
|
})
|
||||||
|
console.log({
|
||||||
|
resolverCost: rawResponse.headers.get("x-resolver-cost"),
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: rawResponse.data,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,42 +0,0 @@
|
|||||||
import "server-only"
|
|
||||||
|
|
||||||
import { ContentstackLivePreview } from "@contentstack/live-preview-utils"
|
|
||||||
import { request as graphqlRequest } from "graphql-request"
|
|
||||||
|
|
||||||
import { env } from "@/env/server"
|
|
||||||
|
|
||||||
import type { DocumentNode } from "graphql"
|
|
||||||
|
|
||||||
import type { Data } from "@/types/request"
|
|
||||||
|
|
||||||
export async function previewRequest<T>(
|
|
||||||
query: string | DocumentNode,
|
|
||||||
variables?: {}
|
|
||||||
): Promise<Data<T>> {
|
|
||||||
try {
|
|
||||||
const hash = ContentstackLivePreview.hash
|
|
||||||
|
|
||||||
if (!hash) {
|
|
||||||
throw new Error("No hash received")
|
|
||||||
}
|
|
||||||
|
|
||||||
const headers = new Headers({
|
|
||||||
access_token: env.CMS_ACCESS_TOKEN,
|
|
||||||
preview_token: env.CMS_PREVIEW_TOKEN,
|
|
||||||
live_preview: hash,
|
|
||||||
})
|
|
||||||
|
|
||||||
const response = await graphqlRequest<T>({
|
|
||||||
document: query,
|
|
||||||
requestHeaders: headers,
|
|
||||||
url: env.CMS_PREVIEW_URL,
|
|
||||||
variables,
|
|
||||||
})
|
|
||||||
|
|
||||||
return { data: response }
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error in preview graphql request")
|
|
||||||
console.error(error)
|
|
||||||
throw new Error("Something went wrong")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
import { ContentstackLivePreview } from "@contentstack/live-preview-utils"
|
|
||||||
import fetchRetry from "fetch-retry"
|
import fetchRetry from "fetch-retry"
|
||||||
import { DocumentNode } from "graphql"
|
import { DocumentNode } from "graphql"
|
||||||
import { GraphQLClient } from "graphql-request"
|
import { GraphQLClient } from "graphql-request"
|
||||||
import { cache } from "react"
|
import { cache } from "react"
|
||||||
|
|
||||||
import { env } from "@/env/server"
|
import { env } from "@/env/server"
|
||||||
|
import { getPreviewHash, isPreview } from "@/lib/previewContext"
|
||||||
|
|
||||||
import { request as _request } from "./_request"
|
import { request as _request } from "./_request"
|
||||||
|
|
||||||
@@ -12,12 +12,12 @@ import { Data } from "@/types/request"
|
|||||||
|
|
||||||
export async function request<T>(
|
export async function request<T>(
|
||||||
query: string | DocumentNode,
|
query: string | DocumentNode,
|
||||||
variables?: {},
|
variables?: Record<string, any>,
|
||||||
params?: RequestInit
|
params?: RequestInit
|
||||||
): Promise<Data<T>> {
|
): Promise<Data<T>> {
|
||||||
const previewHash = ContentstackLivePreview.hash
|
const shouldUsePreview = isPreview(variables?.uid)
|
||||||
|
const previewHash = getPreviewHash()
|
||||||
const cmsUrl = previewHash ? env.CMS_PREVIEW_URL : env.CMS_URL
|
const cmsUrl = shouldUsePreview ? env.CMS_PREVIEW_URL : env.CMS_URL
|
||||||
|
|
||||||
const client = new GraphQLClient(cmsUrl, {
|
const client = new GraphQLClient(cmsUrl, {
|
||||||
fetch: cache(async function (url: URL | RequestInfo, params?: RequestInit) {
|
fetch: cache(async function (url: URL | RequestInfo, params?: RequestInit) {
|
||||||
@@ -31,5 +31,19 @@ export async function request<T>(
|
|||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
return _request(client, query, variables, params)
|
const mergedParams =
|
||||||
|
shouldUsePreview && previewHash
|
||||||
|
? {
|
||||||
|
...params,
|
||||||
|
headers: {
|
||||||
|
...params?.headers,
|
||||||
|
live_preview: previewHash,
|
||||||
|
preview_token: env.CMS_PREVIEW_TOKEN,
|
||||||
|
},
|
||||||
|
cache: undefined,
|
||||||
|
next: undefined,
|
||||||
|
}
|
||||||
|
: params
|
||||||
|
|
||||||
|
return _request(client, query, variables, mergedParams)
|
||||||
}
|
}
|
||||||
|
|||||||
42
lib/previewContext.ts
Normal file
42
lib/previewContext.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { cache } from "react"
|
||||||
|
|
||||||
|
interface PreviewData {
|
||||||
|
hash: string
|
||||||
|
uid: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const getRef = cache((): { current: PreviewData | undefined } => ({
|
||||||
|
current: undefined,
|
||||||
|
}))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the preview hash for the current request
|
||||||
|
*
|
||||||
|
* It works kind of like React's context,
|
||||||
|
* but on the server side, per request.
|
||||||
|
*
|
||||||
|
* @param hash
|
||||||
|
*/
|
||||||
|
export function setPreviewData(data: PreviewData | undefined) {
|
||||||
|
console.log("SETTING HASH")
|
||||||
|
getRef().current = data
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the preview hash set for the current request
|
||||||
|
*/
|
||||||
|
export function getPreviewHash() {
|
||||||
|
return getRef().current?.hash
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the current request is a preview by comparing the uid
|
||||||
|
*/
|
||||||
|
export function isPreview(uid?: string) {
|
||||||
|
const data = getRef().current
|
||||||
|
|
||||||
|
if (uid && data?.hash) {
|
||||||
|
return data.uid === uid
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -19,7 +19,6 @@ export const middleware: NextMiddleware = async (request) => {
|
|||||||
|
|
||||||
const contentTypePathName = pathWithoutTrailingSlash.replace(`/${lang}`, "")
|
const contentTypePathName = pathWithoutTrailingSlash.replace(`/${lang}`, "")
|
||||||
const isPreview = request.nextUrl.pathname.includes("/preview")
|
const isPreview = request.nextUrl.pathname.includes("/preview")
|
||||||
|
|
||||||
const searchParams = new URLSearchParams(request.nextUrl.searchParams)
|
const searchParams = new URLSearchParams(request.nextUrl.searchParams)
|
||||||
|
|
||||||
const { contentType, uid } = await fetchAndCacheEntry(
|
const { contentType, uid } = await fetchAndCacheEntry(
|
||||||
@@ -41,21 +40,10 @@ export const middleware: NextMiddleware = async (request) => {
|
|||||||
const isCurrent = contentType ? contentType.indexOf("current") >= 0 : false
|
const isCurrent = contentType ? contentType.indexOf("current") >= 0 : false
|
||||||
|
|
||||||
if (isPreview) {
|
if (isPreview) {
|
||||||
if (isCurrent) {
|
searchParams.set("isPreview", "true")
|
||||||
searchParams.set("uri", contentTypePathName.replace("/preview", ""))
|
|
||||||
return NextResponse.rewrite(
|
|
||||||
new URL(`/${lang}/preview-current?${searchParams.toString()}`, nextUrl),
|
|
||||||
{
|
|
||||||
request: {
|
|
||||||
headers,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.rewrite(
|
return NextResponse.rewrite(
|
||||||
new URL(
|
new URL(
|
||||||
`/${lang}/preview/${contentType}/${uid}?${searchParams.toString()}`,
|
`/${lang}/${contentType}/${uid}?${searchParams.toString()}`,
|
||||||
nextUrl
|
nextUrl
|
||||||
),
|
),
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user