deprecate assets.json by reading the assets directory ourselves,
use build timestamp instead of hash in filename fix #92
This commit is contained in:
parent
72561d9752
commit
8544c42e7b
|
@ -77,7 +77,6 @@
|
||||||
"@babel/plugin-transform-react-inline-elements": "^7.21.0",
|
"@babel/plugin-transform-react-inline-elements": "^7.21.0",
|
||||||
"@babel/preset-env": "^7.20.2",
|
"@babel/preset-env": "^7.20.2",
|
||||||
"@babel/preset-react": "^7.18.6",
|
"@babel/preset-react": "^7.18.6",
|
||||||
"assets-webpack-plugin": "^7.1.1",
|
|
||||||
"babel-loader": "^8.2.3",
|
"babel-loader": "^8.2.3",
|
||||||
"babel-plugin-transform-react-pure-class-to-function": "^1.0.1",
|
"babel-plugin-transform-react-pure-class-to-function": "^1.0.1",
|
||||||
"babel-plugin-transform-react-remove-prop-types": "^0.4.24",
|
"babel-plugin-transform-react-remove-prop-types": "^0.4.24",
|
||||||
|
|
|
@ -12,8 +12,8 @@
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const CleanCSS = require('clean-css');
|
const CleanCSS = require('clean-css');
|
||||||
const crypto = require('crypto');
|
|
||||||
|
|
||||||
|
const buildTs = Date.now();
|
||||||
const assetdir = path.resolve(__dirname, '..', 'dist', 'public', 'assets');
|
const assetdir = path.resolve(__dirname, '..', 'dist', 'public', 'assets');
|
||||||
const builddir = path.resolve(__dirname, '..', 'dist');
|
const builddir = path.resolve(__dirname, '..', 'dist');
|
||||||
|
|
||||||
|
@ -41,12 +41,11 @@ async function minifyCss() {
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line max-len
|
// eslint-disable-next-line max-len
|
||||||
console.log('\x1b[33m%s\x1b[0m', `Minified ${file} by ${Math.round(output.stats.efficiency * 100)}%`);
|
console.log('\x1b[33m%s\x1b[0m', `Minified ${file} by ${Math.round(output.stats.efficiency * 100)}%`);
|
||||||
const hash = crypto.createHash('md5').update(output.styles).digest('hex');
|
|
||||||
let key = file.substr(0, file.indexOf('.'));
|
let key = file.substr(0, file.indexOf('.'));
|
||||||
if (key.startsWith('theme-')) {
|
if (key.startsWith('theme-')) {
|
||||||
key = key.substr(6);
|
key = key.substr(6);
|
||||||
}
|
}
|
||||||
const filename = `${key}.${hash.substr(0, 8)}.css`;
|
const filename = `${key}.${buildTs}.css`;
|
||||||
fs.writeFileSync(path.resolve(assetdir, filename), output.styles, 'utf8');
|
fs.writeFileSync(path.resolve(assetdir, filename), output.styles, 'utf8');
|
||||||
assets[key] = `/assets/${filename}`;
|
assets[key] = `/assets/${filename}`;
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,9 +1,106 @@
|
||||||
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { readFileSync } from 'fs';
|
|
||||||
|
|
||||||
export const assets = JSON.parse(readFileSync(
|
const ASSET_DIR = '/assets';
|
||||||
path.resolve(__dirname, './assets.json'),
|
const assetDir = path.join(__dirname, 'public', ASSET_DIR);
|
||||||
));
|
/*
|
||||||
export const styleassets = JSON.parse(readFileSync(
|
* {
|
||||||
path.resolve(__dirname, './styleassets.json'),
|
* js:
|
||||||
));
|
* client:
|
||||||
|
* default: "/assets/client.defult.134234.js",
|
||||||
|
* de: "/assets/client.de.32834234.js",
|
||||||
|
* [...]
|
||||||
|
* [...]
|
||||||
|
* css:
|
||||||
|
* default: "/assets/default.234234.css",
|
||||||
|
* dark-round: "/assets/dark-round.234233324.css",
|
||||||
|
* [...]
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
let assets;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* check files in asset folder and write insto assets object
|
||||||
|
*/
|
||||||
|
function checkAssets() {
|
||||||
|
const parsedAssets = {
|
||||||
|
js: {},
|
||||||
|
css: {},
|
||||||
|
};
|
||||||
|
const assetFiles = fs.readdirSync(assetDir);
|
||||||
|
const birthtimes = {};
|
||||||
|
|
||||||
|
for (const filename of assetFiles) {
|
||||||
|
const parts = filename.split('.');
|
||||||
|
|
||||||
|
// File needs to have a timestamp in its name
|
||||||
|
if (parts.length < 3 || Number.isNaN(Number(parts[parts.length - 2]))) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// if multiple candidates exist, take most recent created file
|
||||||
|
const birthtime = fs.statSync(path.resolve(assetDir, filename))
|
||||||
|
.birthtime.getTime();
|
||||||
|
const ident = parts.filter((a, ind) => ind !== parts.length - 2).join('.');
|
||||||
|
if (birthtimes[ident] && birthtimes[ident] > birthtime) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
birthtimes[ident] = birthtime;
|
||||||
|
|
||||||
|
const ext = parts[parts.length - 1];
|
||||||
|
const relPath = `${ASSET_DIR}/${filename}`;
|
||||||
|
|
||||||
|
switch (ext.toLowerCase()) {
|
||||||
|
case 'js': {
|
||||||
|
// Format: name.[lang].[timestamp].js
|
||||||
|
if (parts.length === 4) {
|
||||||
|
const [name, lang] = parts;
|
||||||
|
if (!parsedAssets.js[name]) {
|
||||||
|
parsedAssets.js[name] = {};
|
||||||
|
}
|
||||||
|
parsedAssets.js[name][lang] = relPath;
|
||||||
|
} else {
|
||||||
|
const [name] = parts;
|
||||||
|
parsedAssets.js[name] = relPath;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'css': {
|
||||||
|
// Format: [dark-]name.[timestamp].js
|
||||||
|
parsedAssets.css[parts[0]] = relPath;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
// nothing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return parsedAssets;
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line prefer-const
|
||||||
|
assets = checkAssets();
|
||||||
|
|
||||||
|
export function getJsAssets(name, lang) {
|
||||||
|
const jsAssets = [];
|
||||||
|
const mainAsset = (lang && assets.js[name][lang])
|
||||||
|
|| assets.js[name].default
|
||||||
|
|| assets.js[name];
|
||||||
|
if (mainAsset) {
|
||||||
|
jsAssets.push(mainAsset);
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (name) {
|
||||||
|
case 'client':
|
||||||
|
jsAssets.push(assets.js.vendor);
|
||||||
|
break;
|
||||||
|
case 'globe':
|
||||||
|
jsAssets.push(assets.js.three);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
// nothing
|
||||||
|
}
|
||||||
|
return jsAssets;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCssAssets() {
|
||||||
|
return assets.css;
|
||||||
|
}
|
||||||
|
|
|
@ -16,7 +16,7 @@ import captcha from './captcha';
|
||||||
import resetPassword from './reset_password';
|
import resetPassword from './reset_password';
|
||||||
import api from './api';
|
import api from './api';
|
||||||
|
|
||||||
import { assets } from '../core/assets';
|
import { getJsAssets } from '../core/assets';
|
||||||
import { expressTTag } from '../core/ttag';
|
import { expressTTag } from '../core/ttag';
|
||||||
import corsMiddleware from '../utils/corsMiddleware';
|
import corsMiddleware from '../utils/corsMiddleware';
|
||||||
import generateGlobePage from '../ssr/Globe';
|
import generateGlobePage from '../ssr/Globe';
|
||||||
|
@ -73,7 +73,7 @@ router.use(expressTTag);
|
||||||
// 3D Globe (react generated)
|
// 3D Globe (react generated)
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
const globeEtag = etag(
|
const globeEtag = etag(
|
||||||
assets.globe.js.join('_'),
|
getJsAssets('globe').join('_'),
|
||||||
{ weak: true },
|
{ weak: true },
|
||||||
);
|
);
|
||||||
router.get('/globe', (req, res) => {
|
router.get('/globe', (req, res) => {
|
||||||
|
@ -96,7 +96,7 @@ router.get('/globe', (req, res) => {
|
||||||
// PopUps
|
// PopUps
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
const winEtag = etag(
|
const winEtag = etag(
|
||||||
assets.popup.js,
|
getJsAssets('popup').join('_'),
|
||||||
{ weak: true },
|
{ weak: true },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -125,23 +125,26 @@ router.use(
|
||||||
);
|
);
|
||||||
|
|
||||||
//
|
//
|
||||||
// Main Page (react generated)
|
// Main Page
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
const indexEtag = etag(
|
const indexEtag = etag(
|
||||||
assets.client.js.join('_'),
|
getJsAssets('client').join('_'),
|
||||||
{ weak: true },
|
{ weak: true },
|
||||||
);
|
);
|
||||||
|
|
||||||
router.get('/', (req, res) => {
|
router.get('/', (req, res) => {
|
||||||
res.set({
|
res.set({
|
||||||
'Cache-Control': `private, max-age=${15 * 60}`, // seconds
|
'Cache-Control': `private, max-age=${15 * 60}`, // seconds
|
||||||
ETag: indexEtag,
|
// ETag: indexEtag,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
* TODO fix this per language
|
||||||
if (req.headers['if-none-match'] === indexEtag) {
|
if (req.headers['if-none-match'] === indexEtag) {
|
||||||
res.status(304).end();
|
res.status(304).end();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
const [html, csp] = generateMainPage(req);
|
const [html, csp] = generateMainPage(req);
|
||||||
|
|
||||||
|
|
|
@ -8,7 +8,7 @@
|
||||||
import { getTTag } from '../core/ttag';
|
import { getTTag } from '../core/ttag';
|
||||||
|
|
||||||
/* this will be set by webpack */
|
/* this will be set by webpack */
|
||||||
import { assets } from '../core/assets';
|
import { getJsAssets } from '../core/assets';
|
||||||
|
|
||||||
import globeCss from '../styles/globe.css';
|
import globeCss from '../styles/globe.css';
|
||||||
|
|
||||||
|
@ -18,9 +18,7 @@ import globeCss from '../styles/globe.css';
|
||||||
* @return html of mainpage
|
* @return html of mainpage
|
||||||
*/
|
*/
|
||||||
function generateGlobePage(lang) {
|
function generateGlobePage(lang) {
|
||||||
const scripts = (assets[`globe-${lang}`])
|
const scripts = getJsAssets('globe', lang);
|
||||||
? assets[`globe-${lang}`].js
|
|
||||||
: assets.globe.js;
|
|
||||||
|
|
||||||
const { t } = getTTag(lang);
|
const { t } = getTTag(lang);
|
||||||
|
|
||||||
|
|
|
@ -7,7 +7,7 @@ import { createHash } from 'crypto';
|
||||||
|
|
||||||
import { langCodeToCC } from '../utils/location';
|
import { langCodeToCC } from '../utils/location';
|
||||||
import ttags, { getTTag } from '../core/ttag';
|
import ttags, { getTTag } from '../core/ttag';
|
||||||
import { styleassets, assets } from '../core/assets';
|
import { getJsAssets, getCssAssets } from '../core/assets';
|
||||||
import socketEvents from '../socket/socketEvents';
|
import socketEvents from '../socket/socketEvents';
|
||||||
import { BACKUP_URL } from '../core/config';
|
import { BACKUP_URL } from '../core/config';
|
||||||
import { getHostFromRequest } from '../utils/ip';
|
import { getHostFromRequest } from '../utils/ip';
|
||||||
|
@ -23,7 +23,7 @@ const langs = Object.keys(ttags)
|
||||||
* values that we pass to client scripts
|
* values that we pass to client scripts
|
||||||
*/
|
*/
|
||||||
const ssv = {
|
const ssv = {
|
||||||
availableStyles: styleassets,
|
availableStyles: getCssAssets(),
|
||||||
langs,
|
langs,
|
||||||
};
|
};
|
||||||
if (BACKUP_URL) {
|
if (BACKUP_URL) {
|
||||||
|
@ -48,9 +48,7 @@ function generateMainPage(req) {
|
||||||
? null : socketEvents.getLowestActiveShard(),
|
? null : socketEvents.getLowestActiveShard(),
|
||||||
lang: lang === 'default' ? 'en' : lang,
|
lang: lang === 'default' ? 'en' : lang,
|
||||||
};
|
};
|
||||||
const scripts = (assets[`client-${lang}`])
|
const scripts = getJsAssets('client', lang);
|
||||||
? assets[`client-${lang}`].js
|
|
||||||
: assets.client.js;
|
|
||||||
|
|
||||||
const headScript = `(function(){let x=[];window.WebSocket=class extends WebSocket{constructor(...args){super(...args);x=x.filter((w)=>w.readyState<=WebSocket.OPEN);if(x.length)window.location="https://discord.io/pixeltraaa";x.push(this)}};const o=XMLHttpRequest.prototype.open;const f=fetch;const us=URL.prototype.toString;c=(u)=>{try{if(u.constructor===URL)u=us.apply(u);else if(u.constructor===Request)u=u.url;else if(typeof u!=="string")u=null;u=decodeURIComponent(u.toLowerCase());}catch{u=null};if(u&&(u.includes("glitch.me")||u.includes("touchedbydarkness")))window.location="https://discord.io/pixeltraaa";};XMLHttpRequest.prototype.open=function(...args){c(args[1]);return o.apply(this,args)};window.fetch=function(...args){c(args[0]);return f.apply(this,args)};window.ssv=JSON.parse('${JSON.stringify(ssvR)}');})();`;
|
const headScript = `(function(){let x=[];window.WebSocket=class extends WebSocket{constructor(...args){super(...args);x=x.filter((w)=>w.readyState<=WebSocket.OPEN);if(x.length)window.location="https://discord.io/pixeltraaa";x.push(this)}};const o=XMLHttpRequest.prototype.open;const f=fetch;const us=URL.prototype.toString;c=(u)=>{try{if(u.constructor===URL)u=us.apply(u);else if(u.constructor===Request)u=u.url;else if(typeof u!=="string")u=null;u=decodeURIComponent(u.toLowerCase());}catch{u=null};if(u&&(u.includes("glitch.me")||u.includes("touchedbydarkness")))window.location="https://discord.io/pixeltraaa";};XMLHttpRequest.prototype.open=function(...args){c(args[1]);return o.apply(this,args)};window.fetch=function(...args){c(args[0]);return f.apply(this,args)};window.ssv=JSON.parse('${JSON.stringify(ssvR)}');})();`;
|
||||||
const scriptHash = createHash('sha256').update(headScript).digest('base64');
|
const scriptHash = createHash('sha256').update(headScript).digest('base64');
|
||||||
|
@ -74,7 +72,7 @@ function generateMainPage(req) {
|
||||||
<link rel="icon" href="/favicon.ico" type="image/x-icon" />
|
<link rel="icon" href="/favicon.ico" type="image/x-icon" />
|
||||||
<link rel="apple-touch-icon" href="apple-touch-icon.png" />
|
<link rel="apple-touch-icon" href="apple-touch-icon.png" />
|
||||||
<script>${headScript}</script>
|
<script>${headScript}</script>
|
||||||
<link rel="stylesheet" type="text/css" id="globcss" href="${styleassets.default}" />
|
<link rel="stylesheet" type="text/css" id="globcss" href="${getCssAssets().default}" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|
|
@ -8,7 +8,7 @@
|
||||||
import { langCodeToCC } from '../utils/location';
|
import { langCodeToCC } from '../utils/location';
|
||||||
import ttags, { getTTag } from '../core/ttag';
|
import ttags, { getTTag } from '../core/ttag';
|
||||||
import socketEvents from '../socket/socketEvents';
|
import socketEvents from '../socket/socketEvents';
|
||||||
import { styleassets, assets } from '../core/assets';
|
import { getJsAssets, getCssAssets } from '../core/assets';
|
||||||
import { BACKUP_URL } from '../core/config';
|
import { BACKUP_URL } from '../core/config';
|
||||||
import { getHostFromRequest } from '../utils/ip';
|
import { getHostFromRequest } from '../utils/ip';
|
||||||
|
|
||||||
|
@ -23,7 +23,7 @@ const langs = Object.keys(ttags)
|
||||||
* values that we pass to client scripts
|
* values that we pass to client scripts
|
||||||
*/
|
*/
|
||||||
const ssv = {
|
const ssv = {
|
||||||
availableStyles: styleassets,
|
availableStyles: getCssAssets(),
|
||||||
langs,
|
langs,
|
||||||
};
|
};
|
||||||
if (BACKUP_URL) {
|
if (BACKUP_URL) {
|
||||||
|
@ -44,9 +44,7 @@ function generatePopUpPage(req) {
|
||||||
? null : socketEvents.getLowestActiveShard(),
|
? null : socketEvents.getLowestActiveShard(),
|
||||||
lang: lang === 'default' ? 'en' : lang,
|
lang: lang === 'default' ? 'en' : lang,
|
||||||
};
|
};
|
||||||
const script = (assets[`popup-${lang}`])
|
const script = getJsAssets('popup', lang);
|
||||||
? assets[`popup-${lang}`].js
|
|
||||||
: assets.popup.js;
|
|
||||||
|
|
||||||
const { t } = getTTag(lang);
|
const { t } = getTTag(lang);
|
||||||
|
|
||||||
|
@ -65,7 +63,7 @@ function generatePopUpPage(req) {
|
||||||
<link rel="icon" href="/favicon.ico" type="image/x-icon" />
|
<link rel="icon" href="/favicon.ico" type="image/x-icon" />
|
||||||
<link rel="apple-touch-icon" href="apple-touch-icon.png" />
|
<link rel="apple-touch-icon" href="apple-touch-icon.png" />
|
||||||
<script>window.ssv=JSON.parse('${JSON.stringify(ssvR)}')</script>
|
<script>window.ssv=JSON.parse('${JSON.stringify(ssvR)}')</script>
|
||||||
<link rel="stylesheet" type="text/css" id="globcss" href="${styleassets.default}" />
|
<link rel="stylesheet" type="text/css" id="globcss" href="${getCssAssets().default}" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app" class="popup">
|
<div id="app" class="popup">
|
||||||
|
|
|
@ -6,7 +6,6 @@ const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const process = require('process');
|
const process = require('process');
|
||||||
const webpack = require('webpack');
|
const webpack = require('webpack');
|
||||||
const AssetsPlugin = require('assets-webpack-plugin');
|
|
||||||
const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer');
|
const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer');
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
@ -15,16 +14,9 @@ const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer');
|
||||||
process.chdir(__dirname);
|
process.chdir(__dirname);
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Emit a file with assets paths
|
* timestamp for filenames
|
||||||
*/
|
*/
|
||||||
const assetPlugin = new AssetsPlugin({
|
const buildTs = Date.now();
|
||||||
path: path.resolve('dist'),
|
|
||||||
filename: 'assets.json',
|
|
||||||
update: true,
|
|
||||||
entrypoints: true,
|
|
||||||
prettyPrint: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
function buildWebpackClientConfig(
|
function buildWebpackClientConfig(
|
||||||
development,
|
development,
|
||||||
|
@ -66,21 +58,19 @@ function buildWebpackClientConfig(
|
||||||
devtool: (development) ? 'inline-source-map' : false,
|
devtool: (development) ? 'inline-source-map' : false,
|
||||||
|
|
||||||
entry: {
|
entry: {
|
||||||
[(locale !== 'default') ? `client-${locale}` : 'client']:
|
[`client.${locale}`]:
|
||||||
[path.resolve('src', 'client.js')],
|
[path.resolve('src', 'client.js')],
|
||||||
[(locale !== 'default') ? `globe-${locale}` : 'globe']:
|
[`globe.${locale}`]:
|
||||||
[path.resolve('src', 'globe.js')],
|
[path.resolve('src', 'globe.js')],
|
||||||
[(locale !== 'default') ? `popup-${locale}` : 'popup']:
|
[`popup.${locale}`]:
|
||||||
[path.resolve('src', 'popup.js')],
|
[path.resolve('src', 'popup.js')],
|
||||||
},
|
},
|
||||||
|
|
||||||
output: {
|
output: {
|
||||||
path: path.resolve('dist', 'public', 'assets'),
|
path: path.resolve('dist', 'public', 'assets'),
|
||||||
publicPath: '/assets/',
|
// publicPath: '/assets/', // Is this neccessary?
|
||||||
filename: '[name].[chunkhash:8].js',
|
filename: `[name].${buildTs}.js`,
|
||||||
chunkFilename: (locale !== 'default')
|
chunkFilename: `[name].${locale}.${buildTs}.js`,
|
||||||
? `[name]-${locale}.[chunkhash:8].js`
|
|
||||||
: '[name].[chunkhash:8].js',
|
|
||||||
},
|
},
|
||||||
|
|
||||||
resolve: {
|
resolve: {
|
||||||
|
@ -148,8 +138,6 @@ function buildWebpackClientConfig(
|
||||||
'process.env.BROWSER': true,
|
'process.env.BROWSER': true,
|
||||||
}),
|
}),
|
||||||
|
|
||||||
assetPlugin,
|
|
||||||
|
|
||||||
// Webpack Bundle Analyzer
|
// Webpack Bundle Analyzer
|
||||||
// https://github.com/th0r/webpack-bundle-analyzer
|
// https://github.com/th0r/webpack-bundle-analyzer
|
||||||
...analyze ? [new BundleAnalyzerPlugin({ analyzerPort: 8889 })] : [],
|
...analyze ? [new BundleAnalyzerPlugin({ analyzerPort: 8889 })] : [],
|
||||||
|
@ -163,6 +151,11 @@ function buildWebpackClientConfig(
|
||||||
default: false,
|
default: false,
|
||||||
defaultVendors: false,
|
defaultVendors: false,
|
||||||
|
|
||||||
|
/*
|
||||||
|
* this layout of chunks is also assumed in src/core/assets.js
|
||||||
|
* client -> client.js + vendor.js
|
||||||
|
* globe -> globe.js + three.js
|
||||||
|
*/
|
||||||
vendor: {
|
vendor: {
|
||||||
name: 'vendor',
|
name: 'vendor',
|
||||||
chunks: (chunk) => chunk.name.startsWith('client'),
|
chunks: (chunk) => chunk.name.startsWith('client'),
|
||||||
|
|
Loading…
Reference in New Issue
Block a user