mirror of
https://github.com/discourse/discourse.git
synced 2024-11-23 09:26:54 -06:00
DEV: Use Ember CLI middleware to decorate the index template (#12292)
* DEV: Use Ember CLI middleware to decorate the index template Previously we'd do this on the client side which did not support our full plugin API. Now requests for the index template will contact the dev server for a bootstrap.json and apply it to the current template. * FIX: Allows logins in development mode for Ember CLI
This commit is contained in:
parent
687e09c885
commit
7435d55ea6
@ -1,5 +1,5 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
{{bootstrap-content-for "html-tag"}}
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
@ -7,29 +7,32 @@
|
||||
<meta name="description" content="">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
|
||||
{{bootstrap-content-for "before-script-load"}}
|
||||
{{content-for "before-script-load"}}
|
||||
|
||||
<script src="{{rootURL}}assets/vendor.js"></script>
|
||||
<script src="{{rootURL}}assets/discourse.js"></script>
|
||||
<script src="{{rootURL}}assets/admin.js"></script>
|
||||
|
||||
{{bootstrap-content-for "head"}}
|
||||
{{content-for "head"}}
|
||||
</head>
|
||||
<body>
|
||||
{{bootstrap-content-for "body"}}
|
||||
{{content-for "body"}}
|
||||
|
||||
<section id='main'>
|
||||
</section>
|
||||
|
||||
<script src="{{rootURL}}assets/vendor.js"></script>
|
||||
<script src="{{rootURL}}assets/discourse.js"></script>
|
||||
<script src="{{rootURL}}assets/admin.js"></script>
|
||||
<div id='offscreen-content'>
|
||||
</div>
|
||||
|
||||
{{bootstrap-content-for "hidden-login-form"}}
|
||||
{{bootstrap-content-for "preloaded"}}
|
||||
|
||||
<script src="{{rootURL}}assets/scripts/discourse-boot.js"></script>
|
||||
|
||||
<script>
|
||||
document.addEventListener("discourse-booted", (e) => {
|
||||
const config = e.detail;
|
||||
const app = require(`${config.modulePrefix}/app`)["default"].create(
|
||||
config
|
||||
);
|
||||
app.start();
|
||||
});
|
||||
</script>
|
||||
|
||||
{{bootstrap-content-for "body-footer"}}
|
||||
{{content-for "body-footer"}}
|
||||
</body>
|
||||
</html>
|
||||
|
@ -9,7 +9,6 @@ import I18n from "I18n";
|
||||
import PreloadStore from "discourse/lib/preload-store";
|
||||
import RSVP from "rsvp";
|
||||
import Session from "discourse/models/session";
|
||||
import { camelize } from "@ember/string";
|
||||
import deprecated from "discourse-common/lib/deprecated";
|
||||
import { setDefaultOwner } from "discourse-common/lib/get-owner";
|
||||
import { setIconList } from "discourse-common/lib/icon-library";
|
||||
@ -29,21 +28,12 @@ export default {
|
||||
}
|
||||
|
||||
let setupData;
|
||||
let preloaded;
|
||||
if (app.bootstrap) {
|
||||
// This is annoying but our old way of using `data-*` attributes used camelCase by default
|
||||
setupData = {};
|
||||
Object.keys(app.bootstrap.setup_data).forEach((k) => {
|
||||
setupData[camelize(k)] = app.bootstrap.setup_data[k];
|
||||
});
|
||||
preloaded = app.bootstrap.preloaded;
|
||||
}
|
||||
|
||||
const setupDataElement = document.getElementById("data-discourse-setup");
|
||||
if (setupDataElement) {
|
||||
setupData = setupDataElement.dataset;
|
||||
}
|
||||
|
||||
let preloaded;
|
||||
const preloadedDataElement = document.getElementById("data-preloaded");
|
||||
if (preloadedDataElement) {
|
||||
preloaded = JSON.parse(preloadedDataElement.dataset.preloaded);
|
||||
|
@ -6,6 +6,7 @@ module.exports = function (environment) {
|
||||
environment,
|
||||
rootURL: "/",
|
||||
locationType: "auto",
|
||||
historySupportMiddleware: false,
|
||||
EmberENV: {
|
||||
FEATURES: {
|
||||
// Here you can enable experimental features on an ember canary build
|
||||
|
@ -28,7 +28,9 @@ module.exports = function (defaults) {
|
||||
let discourseRoot = resolve("../../../..");
|
||||
let vendorJs = discourseRoot + "/vendor/assets/javascripts/";
|
||||
|
||||
let app = new EmberApp(defaults, { autoRun: false });
|
||||
let app = new EmberApp(defaults, {
|
||||
autoRun: false,
|
||||
});
|
||||
|
||||
// WARNING: We should only import scripts here if they are not in NPM.
|
||||
// For example: our very specific version of bootstrap-modal.
|
||||
|
234
app/assets/javascripts/discourse/lib/bootstrap-json/index.js
Normal file
234
app/assets/javascripts/discourse/lib/bootstrap-json/index.js
Normal file
@ -0,0 +1,234 @@
|
||||
"use strict";
|
||||
|
||||
const bent = require("bent");
|
||||
const getJSON = bent("json");
|
||||
const { encode } = require("html-entities");
|
||||
const cleanBaseURL = require("clean-base-url");
|
||||
const path = require("path");
|
||||
const fs = require("fs");
|
||||
|
||||
const IGNORE_PATHS = [
|
||||
/\/ember-cli-live-reload\.js$/,
|
||||
/\/session\/[^\/]+\/become$/,
|
||||
];
|
||||
|
||||
function htmlTag(buffer, bootstrap) {
|
||||
let classList = "";
|
||||
if (bootstrap.html_classes) {
|
||||
classList = ` class="${bootstrap.html_classes}"`;
|
||||
}
|
||||
buffer.push(`<html lang="${bootstrap.html_lang}"${classList}>`);
|
||||
}
|
||||
|
||||
function head(buffer, bootstrap) {
|
||||
if (bootstrap.csrf_token) {
|
||||
buffer.push(`<meta name="csrf-param" buffer="authenticity_token">`);
|
||||
buffer.push(`<meta name="csrf-token" buffer="${bootstrap.csrf_token}">`);
|
||||
}
|
||||
if (bootstrap.theme_ids) {
|
||||
buffer.push(
|
||||
`<meta name="discourse_theme_ids" buffer="${bootstrap.theme_ids}">`
|
||||
);
|
||||
}
|
||||
|
||||
let setupData = "";
|
||||
Object.keys(bootstrap.setup_data).forEach((sd) => {
|
||||
let val = bootstrap.setup_data[sd];
|
||||
if (val) {
|
||||
if (Array.isArray(val)) {
|
||||
val = JSON.stringify(val);
|
||||
} else {
|
||||
val = val.toString();
|
||||
}
|
||||
setupData += ` data-${sd.replace(/\_/g, "-")}="${encode(val)}"`;
|
||||
}
|
||||
});
|
||||
buffer.push(`<meta id="data-discourse-setup"${setupData} />`);
|
||||
|
||||
(bootstrap.stylesheets || []).forEach((s) => {
|
||||
let attrs = [];
|
||||
if (s.media) {
|
||||
attrs.push(`media="${s.media}"`);
|
||||
}
|
||||
if (s.target) {
|
||||
attrs.push(`data-target="${s.target}"`);
|
||||
}
|
||||
if (s.theme_id) {
|
||||
attrs.push(`data-theme-id="${s.theme_id}"`);
|
||||
}
|
||||
let link = `<link rel="stylesheet" type="text/css" href="${
|
||||
s.href
|
||||
}" ${attrs.join(" ")}></script>\n`;
|
||||
buffer.push(link);
|
||||
});
|
||||
|
||||
bootstrap.plugin_js.forEach((src) =>
|
||||
buffer.push(`<script src="${src}"></script>`)
|
||||
);
|
||||
|
||||
buffer.push(bootstrap.theme_html.translations);
|
||||
buffer.push(bootstrap.theme_html.js);
|
||||
buffer.push(bootstrap.theme_html.head_tag);
|
||||
buffer.push(bootstrap.html.before_head_close);
|
||||
}
|
||||
|
||||
function beforeScriptLoad(buffer, bootstrap) {
|
||||
buffer.push(bootstrap.html.before_script_load);
|
||||
buffer.push(`<script src="${bootstrap.locale_script}"></script>`);
|
||||
(bootstrap.extra_locales || []).forEach((l) =>
|
||||
buffer.push(`<script src="${l}"></script>`)
|
||||
);
|
||||
}
|
||||
|
||||
function body(buffer, bootstrap) {
|
||||
buffer.push(bootstrap.theme_html.header);
|
||||
buffer.push(bootstrap.html.header);
|
||||
}
|
||||
|
||||
function bodyFooter(buffer, bootstrap) {
|
||||
buffer.push(bootstrap.theme_html.body_tag);
|
||||
buffer.push(bootstrap.html.before_body_close);
|
||||
}
|
||||
|
||||
function hiddenLoginForm(buffer, bootstrap) {
|
||||
if (!bootstrap.preloaded.currentUser) {
|
||||
buffer.push(`
|
||||
<form id='hidden-login-form' method="post" action="${bootstrap.login_path}" style="display: none;">
|
||||
<input name="username" type="text" id="signin_username">
|
||||
<input name="password" type="password" id="signin_password">
|
||||
<input name="redirect" type="hidden">
|
||||
<input type="submit" id="signin-button">
|
||||
</form>
|
||||
`);
|
||||
}
|
||||
}
|
||||
|
||||
function preloaded(buffer, bootstrap) {
|
||||
buffer.push(
|
||||
`<div class="hidden" id="data-preloaded" data-preloaded="${encode(
|
||||
JSON.stringify(bootstrap.preloaded)
|
||||
)}"></div>`
|
||||
);
|
||||
}
|
||||
|
||||
const BUILDERS = {
|
||||
"html-tag": htmlTag,
|
||||
"before-script-load": beforeScriptLoad,
|
||||
head: head,
|
||||
body: body,
|
||||
"hidden-login-form": hiddenLoginForm,
|
||||
preloaded: preloaded,
|
||||
"body-footer": bodyFooter,
|
||||
};
|
||||
|
||||
function replaceIn(bootstrap, template, id) {
|
||||
let buffer = [];
|
||||
BUILDERS[id](buffer, bootstrap);
|
||||
let contents = buffer.filter((b) => b && b.length > 0).join("\n");
|
||||
|
||||
return template.replace(`{{bootstrap-content-for "${id}"}}`, contents);
|
||||
}
|
||||
|
||||
function applyBootstrap(bootstrap, template) {
|
||||
Object.keys(BUILDERS).forEach((id) => {
|
||||
template = replaceIn(bootstrap, template, id);
|
||||
});
|
||||
return template;
|
||||
}
|
||||
|
||||
function decorateIndex(baseUrl, headers) {
|
||||
// eslint-disable-next-line
|
||||
return new Promise((resolve, reject) => {
|
||||
fs.readFile(
|
||||
path.join(process.cwd(), "dist", "index.html"),
|
||||
"utf8",
|
||||
(err, template) => {
|
||||
getJSON(`${baseUrl}/bootstrap.json`, null, headers)
|
||||
.then((json) => {
|
||||
resolve(applyBootstrap(json.bootstrap, template));
|
||||
})
|
||||
.catch(() => {
|
||||
reject(`Could not get ${baseUrl}/bootstrap.json`);
|
||||
});
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
name: require("./package").name,
|
||||
|
||||
isDevelopingAddon() {
|
||||
return true;
|
||||
},
|
||||
|
||||
serverMiddleware(config) {
|
||||
let proxy = config.options.proxy;
|
||||
let app = config.app;
|
||||
let options = config.options;
|
||||
|
||||
let watcher = options.watcher;
|
||||
|
||||
let baseURL =
|
||||
options.rootURL === ""
|
||||
? "/"
|
||||
: cleanBaseURL(options.rootURL || options.baseURL);
|
||||
|
||||
app.use(async (req, res, next) => {
|
||||
try {
|
||||
const results = await watcher;
|
||||
if (this.shouldHandleRequest(req, options)) {
|
||||
let assetPath = req.path.slice(baseURL.length);
|
||||
let isFile = false;
|
||||
|
||||
try {
|
||||
isFile = fs
|
||||
.statSync(path.join(results.directory, assetPath))
|
||||
.isFile();
|
||||
} catch (err) {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
if (!isFile) {
|
||||
let template;
|
||||
try {
|
||||
template = await decorateIndex(proxy, req.headers);
|
||||
} catch (e) {
|
||||
template = `
|
||||
<html>
|
||||
<h1>Discourse Build Error</h1>
|
||||
<p>${e.toString()}</p>
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
res.send(template);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
next();
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
shouldHandleRequest(req, options) {
|
||||
let acceptHeaders = req.headers.accept || [];
|
||||
let hasHTMLHeader = acceptHeaders.indexOf("text/html") !== -1;
|
||||
if (req.method !== "GET") {
|
||||
return false;
|
||||
}
|
||||
if (!hasHTMLHeader) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (IGNORE_PATHS.some((ip) => ip.test(req.path))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let baseURL =
|
||||
options.rootURL === ""
|
||||
? "/"
|
||||
: cleanBaseURL(options.rootURL || options.baseURL);
|
||||
let baseURLRegexp = new RegExp(`^${baseURL}`);
|
||||
return baseURLRegexp.test(req.path);
|
||||
},
|
||||
};
|
@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "bootstrap-json",
|
||||
"keywords": [
|
||||
"ember-addon"
|
||||
],
|
||||
"ember-addon": {
|
||||
"before": [
|
||||
"serve-files-middleware",
|
||||
"history-support-middleware",
|
||||
"proxy-server-middleware"
|
||||
]
|
||||
}
|
||||
}
|
@ -20,6 +20,7 @@
|
||||
"@glimmer/component": "^1.0.0",
|
||||
"@popperjs/core": "^2.4.4",
|
||||
"admin": "^1.0.0",
|
||||
"bent": "^7.3.12",
|
||||
"broccoli-asset-rev": "^3.0.0",
|
||||
"discourse-common": "^1.0.0",
|
||||
"discourse-hbr": "^1.0.0",
|
||||
@ -39,6 +40,7 @@
|
||||
"ember-maybe-import-regenerator": "^0.1.6",
|
||||
"ember-qunit": "^4.6.0",
|
||||
"ember-source": "~3.15.0",
|
||||
"html-entities": "^2.1.0",
|
||||
"loader.js": "^4.7.0",
|
||||
"message-bus-client": "^3.3.0",
|
||||
"mousetrap": "^1.6.5",
|
||||
@ -55,5 +57,10 @@
|
||||
},
|
||||
"ember": {
|
||||
"edition": "default"
|
||||
},
|
||||
"ember-addon": {
|
||||
"paths": [
|
||||
"lib/bootstrap-json"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
@ -163,117 +163,20 @@
|
||||
}
|
||||
});
|
||||
|
||||
define("I18n", ["exports"], function (exports) {
|
||||
return I18n;
|
||||
});
|
||||
window.__widget_helpers = require("discourse-widget-hbs/helpers").default;
|
||||
|
||||
// TODO: Eliminate this global
|
||||
window.virtualDom = require("virtual-dom");
|
||||
|
||||
let head = document.getElementsByTagName("head")[0];
|
||||
function loadScript(src) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let script = document.createElement("script");
|
||||
script.onload = () => resolve();
|
||||
script.src = src;
|
||||
head.appendChild(script);
|
||||
});
|
||||
}
|
||||
|
||||
let isTesting = require("discourse-common/config/environment").isTesting;
|
||||
|
||||
let element = document.querySelector(
|
||||
`meta[name="discourse/config/environment"]`
|
||||
);
|
||||
const config = JSON.parse(
|
||||
decodeURIComponent(element.getAttribute("content"))
|
||||
);
|
||||
fetch("/bootstrap.json")
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
let bootstrap = data.bootstrap;
|
||||
|
||||
config.bootstrap = bootstrap;
|
||||
|
||||
// We know better, we packaged this.
|
||||
config.bootstrap.setup_data.markdown_it_url =
|
||||
"/assets/discourse-markdown.js";
|
||||
|
||||
let locale = bootstrap.locale_script;
|
||||
|
||||
if (bootstrap.csrf_token) {
|
||||
const csrfParam = document.createElement("meta");
|
||||
csrfParam.setAttribute("name", "csrf-param");
|
||||
csrfParam.setAttribute("content", "authenticity_token");
|
||||
head.append(csrfParam);
|
||||
const csrfToken = document.createElement("meta");
|
||||
csrfToken.setAttribute("name", "csrf-token");
|
||||
csrfToken.setAttribute("content", bootstrap.csrf_token);
|
||||
head.append(csrfToken);
|
||||
}
|
||||
(bootstrap.stylesheets || []).forEach((s) => {
|
||||
let link = document.createElement("link");
|
||||
link.setAttribute("rel", "stylesheet");
|
||||
link.setAttribute("type", "text/css");
|
||||
link.setAttribute("href", s.href);
|
||||
if (s.media) {
|
||||
link.setAttribute("media", s.media);
|
||||
}
|
||||
if (s.target) {
|
||||
link.setAttribute("data-target", s.target);
|
||||
}
|
||||
if (s.theme_id) {
|
||||
link.setAttribute("data-theme-id", s.theme_id);
|
||||
}
|
||||
head.append(link);
|
||||
});
|
||||
|
||||
let pluginJs = bootstrap.plugin_js;
|
||||
if (isTesting()) {
|
||||
// pluginJs = pluginJs.concat(bootstrap.plugin_test_js);
|
||||
}
|
||||
|
||||
pluginJs.forEach((src) => {
|
||||
let script = document.createElement("script");
|
||||
script.setAttribute("src", src);
|
||||
head.append(script);
|
||||
});
|
||||
|
||||
if (bootstrap.theme_ids) {
|
||||
let theme_ids = document.createElement("meta");
|
||||
theme_ids.setAttribute("name", "discourse_theme_ids");
|
||||
theme_ids.setAttribute("content", bootstrap.theme_ids);
|
||||
head.append(theme_ids);
|
||||
}
|
||||
|
||||
let htmlElement = document.getElementsByTagName("html")[0];
|
||||
htmlElement.classList = bootstrap.html_classes;
|
||||
htmlElement.setAttribute("lang", bootstrap.html_lang);
|
||||
|
||||
let themeHtml = bootstrap.theme_html;
|
||||
let html = bootstrap.html;
|
||||
|
||||
head.insertAdjacentHTML("beforeend", themeHtml.translations || "");
|
||||
head.insertAdjacentHTML("beforeend", themeHtml.js || "");
|
||||
head.insertAdjacentHTML("beforeend", themeHtml.head_tag || "");
|
||||
|
||||
head.insertAdjacentHTML("afterbegin", html.before_script_load || "");
|
||||
head.insertAdjacentHTML("beforeend", html.before_head_close || "");
|
||||
|
||||
let main = document.getElementById("main");
|
||||
main.insertAdjacentHTML("beforebegin", themeHtml.header || "");
|
||||
main.insertAdjacentHTML("beforebegin", html.header || "");
|
||||
|
||||
let body = document.getElementsByTagName("body")[0];
|
||||
body.insertAdjacentHTML("beforeend", themeHtml.body_tag || "");
|
||||
body.insertAdjacentHTML("beforeend", html.before_body_close || "");
|
||||
|
||||
loadScript(locale).then(() => {
|
||||
define("I18n", ["exports"], function (exports) {
|
||||
return I18n;
|
||||
});
|
||||
window.__widget_helpers = require("discourse-widget-hbs/helpers").default;
|
||||
let extras = (bootstrap.extra_locales || []).map(loadScript);
|
||||
return Promise.all(extras).then(() => {
|
||||
const event = new CustomEvent("discourse-booted", { detail: config });
|
||||
document.dispatchEvent(event);
|
||||
});
|
||||
});
|
||||
});
|
||||
const app = require(`${config.modulePrefix}/app`)["default"].create(config);
|
||||
app.start();
|
||||
})();
|
||||
|
@ -3222,6 +3222,15 @@ bcrypt-pbkdf@^1.0.0:
|
||||
dependencies:
|
||||
tweetnacl "^0.14.3"
|
||||
|
||||
bent@^7.3.12:
|
||||
version "7.3.12"
|
||||
resolved "https://registry.yarnpkg.com/bent/-/bent-7.3.12.tgz#e0a2775d4425e7674c64b78b242af4f49da6b035"
|
||||
integrity sha512-T3yrKnVGB63zRuoco/7Ybl7BwwGZR0lceoVG5XmQyMIH9s19SV5m+a8qam4if0zQuAmOQTyPTPmsQBdAorGK3w==
|
||||
dependencies:
|
||||
bytesish "^0.4.1"
|
||||
caseless "~0.12.0"
|
||||
is-stream "^2.0.0"
|
||||
|
||||
better-assert@~1.0.0:
|
||||
version "1.0.2"
|
||||
resolved "https://registry.yarnpkg.com/better-assert/-/better-assert-1.0.2.tgz#40866b9e1b9e0b55b481894311e68faffaebc522"
|
||||
@ -4052,6 +4061,11 @@ bytes@3.1.0:
|
||||
resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.0.tgz#f6cf7933a360e0588fa9fde85651cdc7f805d1f6"
|
||||
integrity sha512-zauLjrfCG+xvoyaqLoV8bLVXXNGC4JqlxFCutSDWA6fJrTo2ZuvLYTqZ7aHBLZSMOopbzwv8f+wZcVzfVTI2Dg==
|
||||
|
||||
bytesish@^0.4.1:
|
||||
version "0.4.4"
|
||||
resolved "https://registry.yarnpkg.com/bytesish/-/bytesish-0.4.4.tgz#f3b535a0f1153747427aee27256748cff92347e6"
|
||||
integrity sha512-i4uu6M4zuMUiyfZN4RU2+i9+peJh//pXhd9x1oSe1LBkZ3LEbCoygu8W0bXTukU1Jme2txKuotpCZRaC3FLxcQ==
|
||||
|
||||
cacache@^12.0.2:
|
||||
version "12.0.4"
|
||||
resolved "https://registry.yarnpkg.com/cacache/-/cacache-12.0.4.tgz#668bcbd105aeb5f1d92fe25570ec9525c8faa40c"
|
||||
@ -7140,6 +7154,11 @@ html-encoding-sniffer@^1.0.2:
|
||||
dependencies:
|
||||
whatwg-encoding "^1.0.1"
|
||||
|
||||
html-entities@^2.1.0:
|
||||
version "2.1.0"
|
||||
resolved "https://registry.yarnpkg.com/html-entities/-/html-entities-2.1.0.tgz#f5de1f8d5e1f16859a74aa73a90f0db502ca723a"
|
||||
integrity sha512-u+OHVGMH5P1HlaTFp3M4HolRnWepgx5rAnYBo+7/TrBZahuJjgQ4TMv2GjQ4IouGDzkgXYeOI/NQuF95VOUOsQ==
|
||||
|
||||
http-cache-semantics@3.8.1:
|
||||
version "3.8.1"
|
||||
resolved "https://registry.yarnpkg.com/http-cache-semantics/-/http-cache-semantics-3.8.1.tgz#39b0e16add9b605bf0a9ef3d9daaf4843b4cacd2"
|
||||
|
@ -61,7 +61,8 @@ class BootstrapController < ApplicationController
|
||||
html: create_html,
|
||||
theme_html: create_theme_html,
|
||||
html_classes: html_classes,
|
||||
html_lang: html_lang
|
||||
html_lang: html_lang,
|
||||
login_path: main_app.login_path
|
||||
}
|
||||
bootstrap[:extra_locales] = extra_locales if extra_locales.present?
|
||||
bootstrap[:csrf_token] = form_authenticity_token if current_user
|
||||
|
Loading…
Reference in New Issue
Block a user