Merge pull request #1139 from spotify/rugvip/bundleconfig
packages/cli: fix handling of public dir and hook it up to config
This commit is contained in:
@@ -8,47 +8,38 @@
|
||||
name="description"
|
||||
content="Backstage is an open platform for building developer portals"
|
||||
/>
|
||||
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
|
||||
<link rel="apple-touch-icon" href="<%= publicPath %>/logo192.png" />
|
||||
<!--
|
||||
manifest.json provides metadata used when your web app is installed on a
|
||||
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
|
||||
-->
|
||||
<link
|
||||
rel="manifest"
|
||||
href="%PUBLIC_URL%/manifest.json"
|
||||
href="<%= publicPath %>/manifest.json"
|
||||
crossorigin="use-credentials"
|
||||
/>
|
||||
<!--
|
||||
Notice the use of %PUBLIC_URL% in the tags above.
|
||||
It will be replaced with the URL of the `public` folder during the build.
|
||||
Only files inside the `public` folder can be referenced from the HTML.
|
||||
|
||||
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
|
||||
work correctly both with client-side routing and a non-root public URL.
|
||||
Learn how to configure a non-root public URL by running `npm run build`.
|
||||
-->
|
||||
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
|
||||
<link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico" />
|
||||
<link rel="icon" href="<%= publicPath %>/favicon.ico" />
|
||||
<link rel="shortcut icon" href="<%= publicPath %>/favicon.ico" />
|
||||
<link
|
||||
rel="apple-touch-icon"
|
||||
sizes="180x180"
|
||||
href="%PUBLIC_URL%/apple-touch-icon.png"
|
||||
href="<%= publicPath %>/apple-touch-icon.png"
|
||||
/>
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/png"
|
||||
sizes="32x32"
|
||||
href="%PUBLIC_URL%/favicon-32x32.png"
|
||||
href="<%= publicPath %>/favicon-32x32.png"
|
||||
/>
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/png"
|
||||
sizes="16x16"
|
||||
href="%PUBLIC_URL%/favicon-16x16.png"
|
||||
href="<%= publicPath %>/favicon-16x16.png"
|
||||
/>
|
||||
<link
|
||||
rel="mask-icon"
|
||||
href="%PUBLIC_URL%/safari-pinned-tab.svg"
|
||||
href="<%= publicPath %>/safari-pinned-tab.svg"
|
||||
color="#5bbad5"
|
||||
/>
|
||||
<style>
|
||||
@@ -56,9 +47,9 @@
|
||||
min-height: 100%;
|
||||
}
|
||||
</style>
|
||||
<title>Backstage</title>
|
||||
<title><%= app.title %></title>
|
||||
</head>
|
||||
<body style="margin: 0">
|
||||
<body style="margin: 0;">
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
<!--
|
||||
|
||||
@@ -16,7 +16,6 @@
|
||||
|
||||
import { createApp, AlertDisplay, OAuthRequestDialog } from '@backstage/core';
|
||||
import React, { FC } from 'react';
|
||||
import { BrowserRouter as Router } from 'react-router-dom';
|
||||
import Root from './components/Root';
|
||||
import * as plugins from './plugins';
|
||||
import apis from './apis';
|
||||
@@ -34,11 +33,9 @@ const App: FC<{}> = () => (
|
||||
<AppProvider>
|
||||
<AlertDisplay />
|
||||
<OAuthRequestDialog />
|
||||
<Router>
|
||||
<Root>
|
||||
<AppComponent />
|
||||
</Root>
|
||||
</Router>
|
||||
<Root>
|
||||
<AppComponent />
|
||||
</Root>
|
||||
</AppProvider>
|
||||
);
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@
|
||||
"ora": "^4.0.3",
|
||||
"raw-loader": "^4.0.1",
|
||||
"react": "^16.0.0",
|
||||
"react-dev-utils": "^10.2.0",
|
||||
"react-dev-utils": "^10.2.1",
|
||||
"react-hot-loader": "^4.12.21",
|
||||
"recursive-readdir": "^2.2.2",
|
||||
"replace-in-file": "^6.0.0",
|
||||
|
||||
@@ -14,14 +14,17 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { buildBundle } from '../../lib/bundler';
|
||||
import { Command } from 'commander';
|
||||
import { loadConfig } from '@backstage/config-loader';
|
||||
import { ConfigReader } from '@backstage/config';
|
||||
import { buildBundle } from '../../lib/bundler';
|
||||
|
||||
export default async (cmd: Command) => {
|
||||
const appConfigs = await loadConfig();
|
||||
await buildBundle({
|
||||
entry: 'src/index',
|
||||
statsJsonEnabled: cmd.stats,
|
||||
appConfig: await loadConfig(),
|
||||
config: ConfigReader.fromConfigs(appConfigs),
|
||||
appConfigs,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -15,14 +15,17 @@
|
||||
*/
|
||||
|
||||
import { Command } from 'commander';
|
||||
import { serveBundle } from '../../lib/bundler';
|
||||
import { loadConfig } from '@backstage/config-loader';
|
||||
import { ConfigReader } from '@backstage/config';
|
||||
import { serveBundle } from '../../lib/bundler';
|
||||
|
||||
export default async (cmd: Command) => {
|
||||
const appConfigs = await loadConfig();
|
||||
const waitForExit = await serveBundle({
|
||||
entry: 'src/index',
|
||||
checksEnabled: cmd.check,
|
||||
appConfig: await loadConfig(),
|
||||
config: ConfigReader.fromConfigs(appConfigs),
|
||||
appConfigs,
|
||||
});
|
||||
|
||||
await waitForExit();
|
||||
|
||||
@@ -15,14 +15,17 @@
|
||||
*/
|
||||
|
||||
import { Command } from 'commander';
|
||||
import { serveBundle } from '../../lib/bundler';
|
||||
import { loadConfig } from '@backstage/config-loader';
|
||||
import { ConfigReader } from '@backstage/config';
|
||||
import { serveBundle } from '../../lib/bundler';
|
||||
|
||||
export default async (cmd: Command) => {
|
||||
const appConfigs = await loadConfig();
|
||||
const waitForExit = await serveBundle({
|
||||
entry: 'dev/index',
|
||||
checksEnabled: cmd.check,
|
||||
appConfig: await loadConfig(),
|
||||
config: ConfigReader.fromConfigs(appConfigs),
|
||||
appConfigs,
|
||||
});
|
||||
|
||||
await waitForExit();
|
||||
|
||||
@@ -48,6 +48,13 @@ export async function buildBundle(options: BuildOptions) {
|
||||
const previousFileSizes = await measureFileSizesBeforeBuild(paths.targetDist);
|
||||
await fs.emptyDir(paths.targetDist);
|
||||
|
||||
if (paths.targetPublic) {
|
||||
await fs.copy(paths.targetPublic, paths.targetDist, {
|
||||
dereference: true,
|
||||
filter: file => file !== paths.targetHtml,
|
||||
});
|
||||
}
|
||||
|
||||
const { stats } = await build(compiler, isCi).catch(error => {
|
||||
console.log(chalk.red('Failed to compile.\n'));
|
||||
throw new Error(`Failed to compile.\n${error.message || error}`);
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
import webpack from 'webpack';
|
||||
import ForkTsCheckerWebpackPlugin from 'fork-ts-checker-webpack-plugin';
|
||||
import ModuleScopePlugin from 'react-dev-utils/ModuleScopePlugin';
|
||||
import HtmlWebpackPlugin from 'html-webpack-plugin';
|
||||
import { BundlingPaths } from './paths';
|
||||
import { transforms } from './transforms';
|
||||
import { optimization } from './optimization';
|
||||
@@ -33,7 +34,13 @@ export function createConfig(
|
||||
): webpack.Configuration {
|
||||
const { checksEnabled, isDev } = options;
|
||||
|
||||
const { plugins, loaders } = transforms(paths, options);
|
||||
const { plugins, loaders } = transforms(options);
|
||||
|
||||
const baseUrl = options.config.getString('app.baseUrl');
|
||||
if (!baseUrl) {
|
||||
throw new Error('app.baseUrl must be set in config');
|
||||
}
|
||||
const validBaseUrl = new URL(baseUrl, 'https://backstage-app.dev');
|
||||
|
||||
if (checksEnabled) {
|
||||
plugins.push(
|
||||
@@ -53,7 +60,20 @@ export function createConfig(
|
||||
|
||||
plugins.push(
|
||||
new webpack.EnvironmentPlugin({
|
||||
APP_CONFIG: options.appConfig,
|
||||
APP_CONFIG: options.appConfigs,
|
||||
}),
|
||||
);
|
||||
|
||||
plugins.push(
|
||||
new HtmlWebpackPlugin({
|
||||
template: paths.targetHtml,
|
||||
templateParameters: {
|
||||
publicPath: validBaseUrl.pathname.replace(/\/$/, ''),
|
||||
app: {
|
||||
title: options.config.getString('app.title'),
|
||||
baseUrl: validBaseUrl.href,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -85,7 +105,7 @@ export function createConfig(
|
||||
},
|
||||
output: {
|
||||
path: paths.targetDist,
|
||||
publicPath: '/',
|
||||
publicPath: validBaseUrl.pathname,
|
||||
filename: isDev ? '[name].js' : '[name].[hash:8].js',
|
||||
chunkFilename: isDev
|
||||
? '[name].chunk.js'
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { existsSync } from 'fs';
|
||||
import fs from 'fs-extra';
|
||||
import { paths } from '../paths';
|
||||
|
||||
export type BundlingPathsOptions = {
|
||||
@@ -28,20 +28,29 @@ export function resolveBundlingPaths(options: BundlingPathsOptions) {
|
||||
const resolveTargetModule = (path: string) => {
|
||||
for (const ext of ['mjs', 'js', 'ts', 'tsx', 'jsx']) {
|
||||
const filePath = paths.resolveTarget(`${path}.${ext}`);
|
||||
if (existsSync(filePath)) {
|
||||
if (fs.pathExistsSync(filePath)) {
|
||||
return filePath;
|
||||
}
|
||||
}
|
||||
return paths.resolveTarget(`${path}.js`);
|
||||
};
|
||||
|
||||
let targetHtml = paths.resolveTarget(`${entry}.html`);
|
||||
if (!existsSync(targetHtml)) {
|
||||
targetHtml = paths.resolveOwn('templates/serve_index.html');
|
||||
let targetPublic = undefined;
|
||||
let targetHtml = paths.resolveTarget('public/index.html');
|
||||
|
||||
// Prefer public folder
|
||||
if (fs.pathExistsSync(targetHtml)) {
|
||||
targetPublic = paths.resolveTarget('public');
|
||||
} else {
|
||||
targetHtml = paths.resolveTarget(`${entry}.html`);
|
||||
if (!fs.pathExistsSync(targetHtml)) {
|
||||
targetHtml = paths.resolveOwn('templates/serve_index.html');
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
targetHtml,
|
||||
targetPublic,
|
||||
targetPath: paths.resolveTarget('.'),
|
||||
targetDist: paths.resolveTarget('dist'),
|
||||
targetAssets: paths.resolveTarget('assets'),
|
||||
|
||||
@@ -34,7 +34,6 @@ export async function serveBundle(options: ServeOptions) {
|
||||
}
|
||||
|
||||
const protocol = yn(process.env.HTTPS, { default: false }) ? 'https' : 'http';
|
||||
const urls = prepareUrls(protocol, host, port);
|
||||
|
||||
const paths = resolveBundlingPaths(options);
|
||||
const pkgPath = paths.targetPackageJson;
|
||||
@@ -44,7 +43,9 @@ export async function serveBundle(options: ServeOptions) {
|
||||
|
||||
const server = new WebpackDevServer(compiler, {
|
||||
hot: true,
|
||||
publicPath: '/',
|
||||
contentBase: paths.targetPublic,
|
||||
contentBasePublicPath: config.output?.publicPath,
|
||||
publicPath: config.output?.publicPath,
|
||||
historyApiFallback: true,
|
||||
clientLogLevel: 'warning',
|
||||
stats: 'errors-warnings',
|
||||
@@ -61,6 +62,19 @@ export async function serveBundle(options: ServeOptions) {
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO: This signature is available in 10.2.1 but doesn't have types published yet
|
||||
const latestPrepareUrls = prepareUrls as (
|
||||
protocol: string,
|
||||
host: string,
|
||||
port: number,
|
||||
path?: string,
|
||||
) => ReturnType<typeof prepareUrls>;
|
||||
const urls = latestPrepareUrls(
|
||||
protocol,
|
||||
host,
|
||||
port,
|
||||
config.output?.publicPath,
|
||||
);
|
||||
openBrowser(urls.localUrlForBrowser);
|
||||
resolve();
|
||||
});
|
||||
|
||||
@@ -15,20 +15,15 @@
|
||||
*/
|
||||
|
||||
import webpack, { Module, Plugin } from 'webpack';
|
||||
import HtmlWebpackPlugin from 'html-webpack-plugin';
|
||||
import MiniCssExtractPlugin from 'mini-css-extract-plugin';
|
||||
import { BundlingOptions } from './types';
|
||||
import { BundlingPaths } from './paths';
|
||||
|
||||
type Transforms = {
|
||||
loaders: Module['rules'];
|
||||
plugins: Plugin[];
|
||||
};
|
||||
|
||||
export const transforms = (
|
||||
paths: BundlingPaths,
|
||||
options: BundlingOptions,
|
||||
): Transforms => {
|
||||
export const transforms = (options: BundlingOptions): Transforms => {
|
||||
const { isDev } = options;
|
||||
|
||||
const loaders = [
|
||||
@@ -80,12 +75,6 @@ export const transforms = (
|
||||
|
||||
const plugins = new Array<Plugin>();
|
||||
|
||||
plugins.push(
|
||||
new HtmlWebpackPlugin({
|
||||
template: paths.targetHtml,
|
||||
}),
|
||||
);
|
||||
|
||||
if (isDev) {
|
||||
plugins.push(new webpack.HotModuleReplacementPlugin());
|
||||
} else {
|
||||
|
||||
@@ -14,21 +14,24 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { AppConfig } from '@backstage/config';
|
||||
import { AppConfig, Config } from '@backstage/config';
|
||||
import { BundlingPathsOptions } from './paths';
|
||||
|
||||
export type BundlingOptions = {
|
||||
checksEnabled: boolean;
|
||||
isDev: boolean;
|
||||
appConfig: AppConfig[];
|
||||
config: Config;
|
||||
appConfigs: AppConfig[];
|
||||
};
|
||||
|
||||
export type ServeOptions = BundlingPathsOptions & {
|
||||
checksEnabled: boolean;
|
||||
appConfig: AppConfig[];
|
||||
config: Config;
|
||||
appConfigs: AppConfig[];
|
||||
};
|
||||
|
||||
export type BuildOptions = BundlingPathsOptions & {
|
||||
statsJsonEnabled: boolean;
|
||||
appConfig: AppConfig[];
|
||||
config: Config;
|
||||
appConfigs: AppConfig[];
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
app:
|
||||
title: Scaffolded Backstage App
|
||||
baseUrl: http://localhost:3000
|
||||
|
||||
organization:
|
||||
name: Acme Corporation
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { makeStyles } from '@material-ui/core';
|
||||
import { createApp } from '@backstage/core';
|
||||
import React, { FC } from 'react';
|
||||
import { BrowserRouter as Router } from 'react-router-dom';
|
||||
import * as plugins from './plugins';
|
||||
|
||||
const useStyles = makeStyles(theme => ({
|
||||
@@ -33,9 +32,7 @@ const App: FC<{}> = () => {
|
||||
useStyles();
|
||||
return (
|
||||
<AppProvider>
|
||||
<Router>
|
||||
<AppComponent />
|
||||
</Router>
|
||||
<AppComponent />
|
||||
</AppProvider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { ComponentType, FC } from 'react';
|
||||
import React, { ComponentType, FC, useMemo } from 'react';
|
||||
import { Route, Switch, Redirect } from 'react-router-dom';
|
||||
import { AppContextProvider } from './AppContext';
|
||||
import { BackstageApp, AppComponents, AppConfigLoader } from './types';
|
||||
@@ -161,30 +161,58 @@ export class PrivateAppImpl implements BackstageApp {
|
||||
|
||||
getProvider(): ComponentType<{}> {
|
||||
const Provider: FC<{}> = ({ children }) => {
|
||||
const appThemeApi = useMemo(
|
||||
() => AppThemeSelector.createWithStorage(this.themes),
|
||||
[],
|
||||
);
|
||||
|
||||
// Keeping this synchronous when a config loader isn't set simplifies tests a lot
|
||||
const hasConfig = Boolean(this.configLoader);
|
||||
const config = useAsync(this.configLoader || (() => Promise.resolve([])));
|
||||
|
||||
let childNode = children;
|
||||
let noConfigNode = undefined;
|
||||
|
||||
if (hasConfig && config.loading) {
|
||||
const { Progress } = this.components;
|
||||
childNode = <Progress />;
|
||||
noConfigNode = <Progress />;
|
||||
} else if (config.error) {
|
||||
const { BootErrorPage } = this.components;
|
||||
childNode = <BootErrorPage step="load-config" error={config.error} />;
|
||||
noConfigNode = (
|
||||
<BootErrorPage step="load-config" error={config.error} />
|
||||
);
|
||||
}
|
||||
|
||||
// Before the config is loaded we can't use a router, so exit early
|
||||
if (noConfigNode) {
|
||||
return (
|
||||
<ApiProvider apis={ApiRegistry.from([[appThemeApiRef, appThemeApi]])}>
|
||||
<AppThemeProvider>{noConfigNode}</AppThemeProvider>
|
||||
</ApiProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const configReader = ConfigReader.fromConfigs(config.value ?? []);
|
||||
const appApis = ApiRegistry.from([
|
||||
[appThemeApiRef, AppThemeSelector.createWithStorage(this.themes)],
|
||||
[configApiRef, ConfigReader.fromConfigs(config.value ?? [])],
|
||||
[configApiRef, configReader],
|
||||
]);
|
||||
const apis = new ApiAggregator(this.apis, appApis);
|
||||
|
||||
const { Router } = this.components;
|
||||
let { pathname } = new URL(
|
||||
configReader.getString('app.baseUrl') ?? '/',
|
||||
'http://dummy.dev', // baseUrl can be specified as just a path
|
||||
);
|
||||
if (pathname.endsWith('/')) {
|
||||
pathname = pathname.replace(/\/$/, '');
|
||||
}
|
||||
|
||||
return (
|
||||
<ApiProvider apis={apis}>
|
||||
<AppContextProvider app={this}>
|
||||
<AppThemeProvider>{childNode}</AppThemeProvider>
|
||||
<AppThemeProvider>
|
||||
<Router basename={pathname}>{children}</Router>
|
||||
</AppThemeProvider>
|
||||
</AppContextProvider>
|
||||
</ApiProvider>
|
||||
);
|
||||
|
||||
@@ -30,6 +30,7 @@ export type AppComponents = {
|
||||
NotFoundErrorPage: ComponentType<{}>;
|
||||
BootErrorPage: ComponentType<BootErrorPageProps>;
|
||||
Progress: ComponentType<{}>;
|
||||
Router: ComponentType<{ basename?: string }>;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -22,7 +22,7 @@ import privateExports, {
|
||||
BootErrorPageProps,
|
||||
AppConfigLoader,
|
||||
} from '@backstage/core-api';
|
||||
import { BrowserRouter as Router } from 'react-router-dom';
|
||||
import { BrowserRouter, MemoryRouter } from 'react-router-dom';
|
||||
|
||||
import { ErrorPage } from '../layout/ErrorPage';
|
||||
import Progress from '../components/Progress';
|
||||
@@ -87,9 +87,9 @@ export function createApp(options?: AppOptions) {
|
||||
}
|
||||
// TODO: figure out a nicer way to handle routing on the error page, when it can be done.
|
||||
return (
|
||||
<Router>
|
||||
<MemoryRouter>
|
||||
<ErrorPage status="501" statusMessage={message} />
|
||||
</Router>
|
||||
</MemoryRouter>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -100,6 +100,7 @@ export function createApp(options?: AppOptions) {
|
||||
NotFoundErrorPage: DefaultNotFoundPage,
|
||||
BootErrorPage: DefaultBootErrorPage,
|
||||
Progress: Progress,
|
||||
Router: BrowserRouter,
|
||||
...options?.components,
|
||||
};
|
||||
const themes = options?.themes ?? [
|
||||
|
||||
@@ -17,7 +17,6 @@
|
||||
import { hot } from 'react-hot-loader/root';
|
||||
import React, { FC, ComponentType, ReactNode } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import BookmarkIcon from '@material-ui/icons/Bookmark';
|
||||
import {
|
||||
createApp,
|
||||
@@ -94,12 +93,10 @@ class DevAppBuilder {
|
||||
<AlertDisplay />
|
||||
<OAuthRequestDialog />
|
||||
{this.rootChildren}
|
||||
<BrowserRouter>
|
||||
<SidebarPage>
|
||||
{sidebar}
|
||||
<AppComponent />
|
||||
</SidebarPage>
|
||||
</BrowserRouter>
|
||||
<SidebarPage>
|
||||
{sidebar}
|
||||
<AppComponent />
|
||||
</SidebarPage>
|
||||
</AppProvider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -55,6 +55,9 @@ export function wrapInTestApp(
|
||||
NotFoundErrorPage,
|
||||
BootErrorPage,
|
||||
Progress,
|
||||
Router: ({ children }) => (
|
||||
<MemoryRouter initialEntries={routeEntries} children={children} />
|
||||
),
|
||||
},
|
||||
icons: defaultSystemIcons,
|
||||
plugins: [],
|
||||
@@ -79,9 +82,7 @@ export function wrapInTestApp(
|
||||
|
||||
return (
|
||||
<AppProvider>
|
||||
<MemoryRouter initialEntries={routeEntries}>
|
||||
<Route component={Wrapper} />
|
||||
</MemoryRouter>
|
||||
<Route component={Wrapper} />
|
||||
</AppProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15290,7 +15290,7 @@ react-clientside-effect@^1.2.2:
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.0.0"
|
||||
|
||||
react-dev-utils@^10.2.0:
|
||||
react-dev-utils@^10.2.1:
|
||||
version "10.2.1"
|
||||
resolved "https://registry.npmjs.org/react-dev-utils/-/react-dev-utils-10.2.1.tgz#f6de325ae25fa4d546d09df4bb1befdc6dd19c19"
|
||||
integrity sha512-XxTbgJnYZmxuPtY3y/UV0D8/65NKkmaia4rXzViknVnZeVlklSh8u6TnaEYPfAi/Gh1TP4mEOXHI6jQOPbeakQ==
|
||||
|
||||
Reference in New Issue
Block a user