mirror of
https://github.com/Dadoum/anisette-v3-server.git
synced 2024-11-22 03:16:08 +00:00
Now it works flawlessly
This commit is contained in:
parent
d5bd19baf2
commit
90d23ab23a
4
dub.json
4
dub.json
@ -6,11 +6,11 @@
|
|||||||
],
|
],
|
||||||
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"lighttp": "~>0.5.4",
|
|
||||||
"provision": {
|
"provision": {
|
||||||
"repository": "git+https://github.com/Dadoum/Provision.git",
|
"repository": "git+https://github.com/Dadoum/Provision.git",
|
||||||
"version": "e302d138ce949e361b2ed939be82efe8bb15aab1"
|
"version": "e302d138ce949e361b2ed939be82efe8bb15aab1"
|
||||||
},
|
},
|
||||||
"slf4d": "~>2.1.1"
|
"slf4d": "~>2.1.1",
|
||||||
|
"vibe-d": "~>0.9.7-alpha.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -1,7 +1,9 @@
|
|||||||
{
|
{
|
||||||
"fileVersion": 1,
|
"fileVersion": 1,
|
||||||
"versions": {
|
"versions": {
|
||||||
|
"diet-ng": "1.8.1",
|
||||||
"dxml": "0.4.3",
|
"dxml": "0.4.3",
|
||||||
|
"eventcore": "0.9.25",
|
||||||
"hunt": "1.7.17",
|
"hunt": "1.7.17",
|
||||||
"hunt-extra": "1.2.3",
|
"hunt-extra": "1.2.3",
|
||||||
"hunt-http": "0.8.2",
|
"hunt-http": "0.8.2",
|
||||||
@ -10,11 +12,18 @@
|
|||||||
"libasync": "0.9.2",
|
"libasync": "0.9.2",
|
||||||
"lighttp": "0.5.4",
|
"lighttp": "0.5.4",
|
||||||
"memutils": "1.0.9",
|
"memutils": "1.0.9",
|
||||||
|
"mir-linux-kernel": "1.0.1",
|
||||||
|
"openssl": "3.3.0",
|
||||||
|
"openssl-static": "1.0.2+3.0.8",
|
||||||
"plist": "~master",
|
"plist": "~master",
|
||||||
"plist-d": {"version":"d494cf3fe79a2bb20583173c0c8cf85ef33b719e","repository":"git+https://github.com/Dadoum/libplist-d.git"},
|
"plist-d": {"version":"d494cf3fe79a2bb20583173c0c8cf85ef33b719e","repository":"git+https://github.com/Dadoum/libplist-d.git"},
|
||||||
"provision": {"version":"e302d138ce949e361b2ed939be82efe8bb15aab1","repository":"git+https://github.com/Dadoum/Provision.git"},
|
"provision": {"version":"e302d138ce949e361b2ed939be82efe8bb15aab1","repository":"git+https://github.com/Dadoum/Provision.git"},
|
||||||
"slf4d": "2.1.1",
|
"slf4d": "2.1.1",
|
||||||
|
"stdx-allocator": "2.77.5",
|
||||||
|
"taggedalgebraic": "0.11.22",
|
||||||
"urld": "2.1.1",
|
"urld": "2.1.1",
|
||||||
|
"vibe-core": "2.2.0",
|
||||||
|
"vibe-d": "0.9.7-alpha.2",
|
||||||
"xbuffer": "1.0.0"
|
"xbuffer": "1.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
293
source/app.d
293
source/app.d
@ -14,9 +14,10 @@ import std.uni;
|
|||||||
import std.uuid;
|
import std.uuid;
|
||||||
import std.zip;
|
import std.zip;
|
||||||
|
|
||||||
import lighttp;
|
import vibe.d;
|
||||||
|
|
||||||
import slf4d;
|
import slf4d;
|
||||||
|
import slf4d: Logger;
|
||||||
import slf4d.default_provider;
|
import slf4d.default_provider;
|
||||||
|
|
||||||
import provision;
|
import provision;
|
||||||
@ -30,15 +31,13 @@ enum dsId = -2;
|
|||||||
__gshared ADI v1Adi;
|
__gshared ADI v1Adi;
|
||||||
__gshared Device v1Device;
|
__gshared Device v1Device;
|
||||||
|
|
||||||
void main(string[] args)
|
int main(string[] args) {
|
||||||
{
|
|
||||||
debug {
|
debug {
|
||||||
configureLoggingProvider(new shared DefaultProvider(true, Levels.DEBUG));
|
configureLoggingProvider(new shared DefaultProvider(true, Levels.DEBUG));
|
||||||
} else {
|
} else {
|
||||||
configureLoggingProvider(new shared DefaultProvider(true, Levels.INFO));
|
configureLoggingProvider(new shared DefaultProvider(true, Levels.INFO));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
Logger log = getLogger();
|
Logger log = getLogger();
|
||||||
log.info(brandingCode);
|
log.info(brandingCode);
|
||||||
string hostname = "0.0.0.0";
|
string hostname = "0.0.0.0";
|
||||||
@ -54,7 +53,7 @@ void main(string[] args)
|
|||||||
|
|
||||||
if (helpInformation.helpWanted) {
|
if (helpInformation.helpWanted) {
|
||||||
defaultGetoptPrinter("anisette-server with v3 support", helpInformation.options);
|
defaultGetoptPrinter("anisette-server with v3 support", helpInformation.options);
|
||||||
return;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!file.exists(configurationPath)) {
|
if (!file.exists(configurationPath)) {
|
||||||
@ -127,27 +126,27 @@ void main(string[] args)
|
|||||||
log.info("Provisioning done!");
|
log.info("Provisioning done!");
|
||||||
}
|
}
|
||||||
|
|
||||||
Server server = new Server();
|
|
||||||
server.host(hostname, port);
|
|
||||||
server.router.add(new AnisetteUnifiedServer());
|
|
||||||
server.run();
|
|
||||||
/+
|
|
||||||
auto server = HttpServer.builder()
|
|
||||||
.setListener(port, hostname)
|
|
||||||
.addRoute("/", (RoutingContext context) {
|
|
||||||
})
|
|
||||||
.addRoute("/v3/client_info", (RoutingContext context) {
|
|
||||||
})
|
|
||||||
.addRoute("/v3/get_headers", (RoutingContext context) {
|
|
||||||
})
|
|
||||||
.websocket("/v3/provisioning_session", new SocketProvisioningSessionHandler()).build();
|
|
||||||
|
|
||||||
server.start();
|
// Create the router that will map the incoming requests to request handlers
|
||||||
+/
|
auto router = new URLRouter();
|
||||||
|
// Register SampleService as a web service
|
||||||
|
router.registerWebInterface(new AnisetteService());
|
||||||
|
|
||||||
|
// Start up the HTTP server.
|
||||||
|
auto settings = new HTTPServerSettings;
|
||||||
|
settings.port = port;
|
||||||
|
settings.bindAddresses = [hostname];
|
||||||
|
settings.sessionStore = new MemorySessionStore;
|
||||||
|
|
||||||
|
auto listener = listenHTTP(settings, router);
|
||||||
|
|
||||||
|
return runApplication(&args);
|
||||||
}
|
}
|
||||||
|
|
||||||
class AnisetteUnifiedServer {
|
class AnisetteService {
|
||||||
@Get("") handleV1Request(ServerResponse response) {
|
@method(HTTPMethod.GET)
|
||||||
|
@path("/")
|
||||||
|
void handleV1Request(HTTPServerRequest req, HTTPServerResponse res) {
|
||||||
import std.datetime.systime;
|
import std.datetime.systime;
|
||||||
import std.datetime.timezone;
|
import std.datetime.timezone;
|
||||||
import core.time;
|
import core.time;
|
||||||
@ -173,13 +172,14 @@ class AnisetteUnifiedServer {
|
|||||||
"X-Mme-Device-Id": v1Device.uniqueDeviceIdentifier,
|
"X-Mme-Device-Id": v1Device.uniqueDeviceIdentifier,
|
||||||
];
|
];
|
||||||
|
|
||||||
response.contentType = "application/json";
|
|
||||||
response.headers["Implementation-Version"] = brandingCode;
|
response.headers["Implementation-Version"] = brandingCode;
|
||||||
response.body = responseJson.toString(JSONOptions.doNotEscapeSlashes);
|
res.writeBody(responseJson.toString(JSONOptions.doNotEscapeSlashes), "application/json");
|
||||||
log.infoF!"[>>] 200 OK %s"(responseJson);
|
log.infoF!"[>>] 200 OK %s"(responseJson);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get("v3/client_info") void getClientInfo(ServerResponse response) {
|
@method(HTTPMethod.GET)
|
||||||
|
@path("/v3/client_info")
|
||||||
|
void getClientInfo(HTTPServerRequest req, HTTPServerResponse res) {
|
||||||
auto log = getLogger();
|
auto log = getLogger();
|
||||||
log.info("[<<] anisette-v3 /v3/client_info");
|
log.info("[<<] anisette-v3 /v3/client_info");
|
||||||
JSONValue responseJson = [
|
JSONValue responseJson = [
|
||||||
@ -188,22 +188,30 @@ class AnisetteUnifiedServer {
|
|||||||
];
|
];
|
||||||
|
|
||||||
response.headers["Implementation-Version"] = brandingCode;
|
response.headers["Implementation-Version"] = brandingCode;
|
||||||
response.body = responseJson.toString(JSONOptions.doNotEscapeSlashes);
|
response.writeBody(responseJson.toString(JSONOptions.doNotEscapeSlashes), "application/json");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post("v3/get_headers") void getHeaders(ServerResponse res, ServerRequest req) {
|
@method(HTTPMethod.POST)
|
||||||
|
@path("/v3/get_headers")
|
||||||
|
void getHeaders(HTTPServerRequest req, HTTPServerResponse res) {
|
||||||
auto log = getLogger();
|
auto log = getLogger();
|
||||||
log.info("[<<] anisette-v3 /v3/get_headers");
|
log.info("[<<] anisette-v3 /v3/get_headers");
|
||||||
string identifier = "(null)";
|
string identifier = "(null)";
|
||||||
try {
|
try {
|
||||||
import std.uuid;
|
import std.uuid;
|
||||||
auto json = parseJSON(req.body());
|
auto json = req.json();
|
||||||
ubyte[] identifierBytes = Base64.decode(json["identifier"].str());
|
ubyte[] identifierBytes = Base64.decode(json["identifier"].to!string());
|
||||||
ubyte[] adi_pb = Base64.decode(json["adi_pb"].str());
|
ubyte[] adi_pb = Base64.decode(json["adi_pb"].to!string());
|
||||||
identifier = UUID(identifierBytes[0..16]).toString();
|
identifier = UUID(identifierBytes[0..16]).toString();
|
||||||
|
|
||||||
auto provisioningPath = file.getcwd()
|
auto provisioningPath = file.getcwd()
|
||||||
.buildPath("provisioning")
|
.buildPath("provisioning")
|
||||||
.buildPath(identifier);
|
.buildPath(identifier);
|
||||||
|
|
||||||
|
if (file.exists(provisioningPath)) {
|
||||||
|
file.rmdirRecurse(provisioningPath);
|
||||||
|
}
|
||||||
|
|
||||||
file.mkdir(provisioningPath);
|
file.mkdir(provisioningPath);
|
||||||
file.write(provisioningPath.buildPath("adi.pb"), adi_pb);
|
file.write(provisioningPath.buildPath("adi.pb"), adi_pb);
|
||||||
ADI adi = new ADI(libraryPath);
|
ADI adi = new ADI(libraryPath);
|
||||||
@ -220,14 +228,16 @@ class AnisetteUnifiedServer {
|
|||||||
"X-Apple-I-MD-RINFO": "17106176",
|
"X-Apple-I-MD-RINFO": "17106176",
|
||||||
];
|
];
|
||||||
res.headers["Implementation-Version"] = brandingCode;
|
res.headers["Implementation-Version"] = brandingCode;
|
||||||
res.body = response.toString(JSONOptions.doNotEscapeSlashes);
|
res.writeBody(response.toString(JSONOptions.doNotEscapeSlashes), "application/json");
|
||||||
|
log.info("[>>] anisette-v3 /v3/get_headers OK.");
|
||||||
} catch (Throwable t) {
|
} catch (Throwable t) {
|
||||||
JSONValue error = [
|
JSONValue error = [
|
||||||
"result": "GetHeadersError",
|
"result": "GetHeadersError",
|
||||||
"message": typeid(t).name ~ ": " ~ t.msg
|
"message": typeid(t).name ~ ": " ~ t.msg
|
||||||
];
|
];
|
||||||
res.headers["Implementation-Version"] = brandingCode;
|
res.headers["Implementation-Version"] = brandingCode;
|
||||||
res.body = error.toString(JSONOptions.doNotEscapeSlashes);
|
log.info("[>>] anisette-v3 /v3/get_headers error.");
|
||||||
|
res.writeBody(error.toString(JSONOptions.doNotEscapeSlashes), "application/json");
|
||||||
} finally {
|
} finally {
|
||||||
if (file.exists(
|
if (file.exists(
|
||||||
file.getcwd()
|
file.getcwd()
|
||||||
@ -243,111 +253,130 @@ class AnisetteUnifiedServer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get("v3/provisioning_session") class ProvisioningSocket : WebSocket {
|
enum timeout = dur!"msecs"(1250);
|
||||||
SocketProvisioningSessionState state;
|
|
||||||
ADI adi;
|
|
||||||
uint session;
|
|
||||||
|
|
||||||
string ip;
|
@method(HTTPMethod.GET)
|
||||||
|
@path("/v3/provisioning_session")
|
||||||
void onConnect(ServerRequest request) {
|
void provisionSession(scope WebSocket socket) {
|
||||||
getLogger().info("[<<] anisette-v3 /v3/provisioning_session open");
|
auto log = getLogger();
|
||||||
state = SocketProvisioningSessionState.waitingForIdentifier;
|
log.info("[<<] anisette-v3 /v3/provisionSession connected.");
|
||||||
adi = null;
|
|
||||||
session = 0;
|
|
||||||
ip = "";
|
|
||||||
|
|
||||||
|
try {
|
||||||
JSONValue giveIdentifier = [
|
JSONValue giveIdentifier = [
|
||||||
"result": "GiveIdentifier"
|
"result": "GiveIdentifier"
|
||||||
];
|
];
|
||||||
send(giveIdentifier.toString(JSONOptions.doNotEscapeSlashes));
|
socket.send(giveIdentifier.toString(JSONOptions.doNotEscapeSlashes));
|
||||||
}
|
|
||||||
|
|
||||||
override void onClose() {
|
log.info("[>>] Asking for identifier.");
|
||||||
getLogger().infoF!("[<< %s] anisette-v3 /v3/provisioning_session close")(ip);
|
if (!socket.waitForData(timeout)) {
|
||||||
}
|
JSONValue timeoutJs = [
|
||||||
|
"result": "Timeout"
|
||||||
override void onReceive(ubyte[] data) {
|
|
||||||
string text = cast(string) data;
|
|
||||||
auto log = getLogger();
|
|
||||||
try {
|
|
||||||
final switch (state) with (SocketProvisioningSessionState) {
|
|
||||||
case waitingForIdentifier:
|
|
||||||
auto res = parseJSON(text);
|
|
||||||
ubyte[] requestedIdentifier = Base64.decode(res["identifier"].str());
|
|
||||||
|
|
||||||
if (requestedIdentifier.length != 16) {
|
|
||||||
JSONValue response = [
|
|
||||||
"result": "InvalidIdentifier"
|
|
||||||
];
|
|
||||||
|
|
||||||
log.infoF!("[>> %s] It is invalid.")(ip);
|
|
||||||
send(response.toString());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
string identifier = UUID(requestedIdentifier[0..16]).toString();
|
|
||||||
log.infoF!("[<< %s] Received an identifier (%s).")(ip, identifier);
|
|
||||||
|
|
||||||
adi = new ADI(libraryPath);
|
|
||||||
adi.provisioningPath = file.getcwd().buildPath("provisioning").buildPath(identifier);
|
|
||||||
adi.identifier = identifier.toUpper()[0..16];
|
|
||||||
state = waitingForStartProvisioningData;
|
|
||||||
JSONValue response = [
|
|
||||||
"result": "GiveStartProvisioningData"
|
|
||||||
];
|
|
||||||
log.infoF!("[>> %s] Okay gimme spim.")(ip);
|
|
||||||
send(response.toString(JSONOptions.doNotEscapeSlashes));
|
|
||||||
break;
|
|
||||||
case waitingForStartProvisioningData:
|
|
||||||
auto res = parseJSON(text);
|
|
||||||
string spim = res["spim"].str();
|
|
||||||
log.infoF!("[<< %s] Received SPIM.")(ip);
|
|
||||||
auto cpimAndCo = adi.startProvisioning(-2, Base64.decode(spim));
|
|
||||||
session = cpimAndCo.session;
|
|
||||||
state = waitingForEndProvisioning;
|
|
||||||
JSONValue response = [
|
|
||||||
"result": "GiveEndProvisioningData",
|
|
||||||
"cpim": Base64.encode(cpimAndCo.clientProvisioningIntermediateMetadata)
|
|
||||||
];
|
|
||||||
log.infoF!("[>> %s] Okay gimme ptm tk.")(ip);
|
|
||||||
send(response.toString(JSONOptions.doNotEscapeSlashes));
|
|
||||||
break;
|
|
||||||
case waitingForEndProvisioning:
|
|
||||||
auto res = parseJSON(text);
|
|
||||||
string ptm = res["ptm"].str();
|
|
||||||
string tk = res["tk"].str();
|
|
||||||
log.infoF!("[<< %s] Received PTM and TK.")(ip);
|
|
||||||
adi.endProvisioning(session, Base64.decode(ptm), Base64.decode(tk));
|
|
||||||
JSONValue response = [
|
|
||||||
"result": "ProvisioningSuccess",
|
|
||||||
"adi_pb": Base64.encode(
|
|
||||||
cast(ubyte[]) file.read(
|
|
||||||
adi.provisioningPath()
|
|
||||||
.buildPath("adi.pb")
|
|
||||||
)
|
|
||||||
)
|
|
||||||
];
|
|
||||||
log.infoF!("[>> %s] Okay all right here is your provisioning data.")(ip);
|
|
||||||
send(response.toString(JSONOptions.doNotEscapeSlashes));
|
|
||||||
break;
|
|
||||||
// +/
|
|
||||||
}
|
|
||||||
} catch (Throwable t) {
|
|
||||||
JSONValue error = [
|
|
||||||
"result": "NonStandard-" ~ typeid(t).name,
|
|
||||||
"message": t.msg
|
|
||||||
];
|
];
|
||||||
log.errorF!"[>>] anisette-v3 error: %s"(t);
|
log.info("[>>] Timeout!");
|
||||||
// connection.sendText(error.toString()).then((_) {
|
socket.send(timeoutJs.toString(JSONOptions.doNotEscapeSlashes));
|
||||||
// connection.close();
|
socket.close();
|
||||||
// });
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
auto res = parseJSON(socket.receiveText());
|
||||||
|
ubyte[] requestedIdentifier = Base64.decode(res["identifier"].str());
|
||||||
|
log.info("[>>] Got it.");
|
||||||
|
|
||||||
|
if (requestedIdentifier.length != 16) {
|
||||||
|
JSONValue response = [
|
||||||
|
"result": "InvalidIdentifier"
|
||||||
|
];
|
||||||
|
|
||||||
|
log.info("[>>] It is invalid.");
|
||||||
|
socket.send(response.toString(JSONOptions.doNotEscapeSlashes));
|
||||||
|
socket.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
string identifier = UUID(requestedIdentifier[0..16]).toString();
|
||||||
|
log.infoF!("[<<] Correct identifier (%s).")(identifier);
|
||||||
|
|
||||||
|
ADI adi = new ADI(libraryPath);
|
||||||
|
auto provisioningPath = file.getcwd()
|
||||||
|
.buildPath("provisioning")
|
||||||
|
.buildPath(identifier);
|
||||||
|
adi.provisioningPath = provisioningPath;
|
||||||
|
scope(exit) {
|
||||||
|
if (file.exists(provisioningPath)) {
|
||||||
|
file.rmdirRecurse(provisioningPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
adi.identifier = identifier.toUpper()[0..16];
|
||||||
|
|
||||||
|
JSONValue response = [
|
||||||
|
"result": "GiveStartProvisioningData"
|
||||||
|
];
|
||||||
|
log.info("[>>] Okay asking for spim now.");
|
||||||
|
|
||||||
|
socket.send(response.toString(JSONOptions.doNotEscapeSlashes));
|
||||||
|
|
||||||
|
if (!socket.waitForData(timeout)) {
|
||||||
|
JSONValue timeoutJs = [
|
||||||
|
"result": "Timeout"
|
||||||
|
];
|
||||||
|
log.info("[>>] Timeout!");
|
||||||
|
socket.send(timeoutJs.toString(JSONOptions.doNotEscapeSlashes));
|
||||||
|
socket.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res = parseJSON(socket.receiveText());
|
||||||
|
|
||||||
|
string spim = res["spim"].str();
|
||||||
|
log.info("[<<] Received SPIM.");
|
||||||
|
auto cpimAndCo = adi.startProvisioning(-2, Base64.decode(spim));
|
||||||
|
auto session = cpimAndCo.session;
|
||||||
|
|
||||||
|
response = [
|
||||||
|
"result": "GiveEndProvisioningData",
|
||||||
|
"cpim": Base64.encode(cpimAndCo.clientProvisioningIntermediateMetadata)
|
||||||
|
];
|
||||||
|
log.info("[>>] Okay gimme ptm tk.");
|
||||||
|
|
||||||
|
socket.send(response.toString(JSONOptions.doNotEscapeSlashes));
|
||||||
|
|
||||||
|
if (!socket.waitForData(timeout)) {
|
||||||
|
JSONValue timeoutJs = [
|
||||||
|
"result": "Timeout"
|
||||||
|
];
|
||||||
|
log.info("[>>] Timeout!");
|
||||||
|
socket.send(timeoutJs.toString(JSONOptions.doNotEscapeSlashes));
|
||||||
|
socket.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res = parseJSON(socket.receiveText());
|
||||||
|
string ptm = res["ptm"].str();
|
||||||
|
string tk = res["tk"].str();
|
||||||
|
log.info("[<<] Received PTM and TK.");
|
||||||
|
|
||||||
|
adi.endProvisioning(session, Base64.decode(ptm), Base64.decode(tk));
|
||||||
|
|
||||||
|
response = [
|
||||||
|
"result": "ProvisioningSuccess",
|
||||||
|
"adi_pb": Base64.encode(
|
||||||
|
cast(ubyte[]) file.read(
|
||||||
|
adi.provisioningPath()
|
||||||
|
.buildPath("adi.pb")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
];
|
||||||
|
log.info("[>>] Okay all right here is your provisioning data.");
|
||||||
|
|
||||||
|
socket.send(response.toString(JSONOptions.doNotEscapeSlashes));
|
||||||
|
} catch (Throwable t) {
|
||||||
|
JSONValue error = [
|
||||||
|
"result": "NonStandard-" ~ typeid(t).name,
|
||||||
|
"message": t.msg
|
||||||
|
];
|
||||||
|
log.errorF!"[>>] anisette-v3 error: %s"(t);
|
||||||
|
socket.send(error.toString());
|
||||||
|
} finally {
|
||||||
|
socket.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum SocketProvisioningSessionState {
|
|
||||||
waitingForIdentifier,
|
|
||||||
waitingForStartProvisioningData,
|
|
||||||
waitingForEndProvisioning,
|
|
||||||
}
|
|
||||||
|
Loading…
Reference in New Issue
Block a user