Add InputStream mode for signing zips
This commit is contained in:
parent
53237f3ae0
commit
963edfe8ab
@ -0,0 +1,34 @@
|
|||||||
|
package com.topjohnwu.magisk.container;
|
||||||
|
|
||||||
|
import java.io.ByteArrayInputStream;
|
||||||
|
import java.io.ByteArrayOutputStream;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.io.OutputStream;
|
||||||
|
|
||||||
|
public class ByteArrayStream extends ByteArrayOutputStream {
|
||||||
|
public byte[] getBuf() {
|
||||||
|
return buf;
|
||||||
|
}
|
||||||
|
public synchronized void readFrom(InputStream is) {
|
||||||
|
readFrom(is, Integer.MAX_VALUE);
|
||||||
|
}
|
||||||
|
public synchronized void readFrom(InputStream is, int len) {
|
||||||
|
int read;
|
||||||
|
byte buffer[] = new byte[4096];
|
||||||
|
try {
|
||||||
|
while ((read = is.read(buffer, 0, len < buffer.length ? len : buffer.length)) > 0) {
|
||||||
|
write(buffer, 0, read);
|
||||||
|
len -= read;
|
||||||
|
}
|
||||||
|
} catch (IOException e) {
|
||||||
|
e.printStackTrace();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
public synchronized void writeTo(OutputStream out, int off, int len) throws IOException {
|
||||||
|
out.write(buf, off, len);
|
||||||
|
}
|
||||||
|
public ByteArrayInputStream getInputStream() {
|
||||||
|
return new ByteArrayInputStream(buf, 0, count);
|
||||||
|
}
|
||||||
|
}
|
156
app/src/main/java/com/topjohnwu/magisk/container/JarMap.java
Normal file
156
app/src/main/java/com/topjohnwu/magisk/container/JarMap.java
Normal file
@ -0,0 +1,156 @@
|
|||||||
|
package com.topjohnwu.magisk.container;
|
||||||
|
|
||||||
|
import java.io.BufferedInputStream;
|
||||||
|
import java.io.ByteArrayInputStream;
|
||||||
|
import java.io.ByteArrayOutputStream;
|
||||||
|
import java.io.Closeable;
|
||||||
|
import java.io.File;
|
||||||
|
import java.io.FileInputStream;
|
||||||
|
import java.io.FileNotFoundException;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.io.OutputStream;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.Enumeration;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.jar.JarEntry;
|
||||||
|
import java.util.jar.JarFile;
|
||||||
|
import java.util.jar.JarInputStream;
|
||||||
|
import java.util.jar.Manifest;
|
||||||
|
import java.util.zip.ZipEntry;
|
||||||
|
import java.util.zip.ZipFile;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* A universal random access interface for both JarFile and JarInputStream
|
||||||
|
*
|
||||||
|
* In the case when JarInputStream is provided to constructor, the whole stream
|
||||||
|
* will be loaded into memory for random access purposes.
|
||||||
|
* On the other hand, when a JarFile is provided, it simply works as a wrapper.
|
||||||
|
* */
|
||||||
|
|
||||||
|
public class JarMap implements Closeable, AutoCloseable {
|
||||||
|
private JarFile jarFile;
|
||||||
|
private JarInputStream jis;
|
||||||
|
private InputStream is;
|
||||||
|
private File file;
|
||||||
|
private boolean isInputStream = false, hasLoaded = false, verify;
|
||||||
|
private LinkedHashMap<String, JarEntry> bufMap = new LinkedHashMap<>();
|
||||||
|
|
||||||
|
public JarMap(File file) throws IOException {
|
||||||
|
this(file, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public JarMap(File file, boolean verify) throws IOException {
|
||||||
|
this(file, verify, ZipFile.OPEN_READ);
|
||||||
|
}
|
||||||
|
|
||||||
|
public JarMap(File file, boolean verify, int mode) throws IOException {
|
||||||
|
this.file = file;
|
||||||
|
jarFile = new JarFile(file, verify, mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
public JarMap(String name) throws IOException {
|
||||||
|
this(new File(name));
|
||||||
|
}
|
||||||
|
|
||||||
|
public JarMap(String name, boolean verify) throws IOException {
|
||||||
|
this(new File(name), verify);
|
||||||
|
}
|
||||||
|
|
||||||
|
public JarMap(InputStream is) throws IOException {
|
||||||
|
this(is, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public JarMap(InputStream is, boolean verify) throws IOException {
|
||||||
|
isInputStream = true;
|
||||||
|
this.is = is;
|
||||||
|
this.verify = verify;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void loadJarInputStream() {
|
||||||
|
if (!isInputStream || hasLoaded) return;
|
||||||
|
hasLoaded = true;
|
||||||
|
JarEntry entry;
|
||||||
|
try {
|
||||||
|
jis = new JarInputStream(is, verify);
|
||||||
|
while ((entry = jis.getNextJarEntry()) != null) {
|
||||||
|
bufMap.put(entry.getName(), new JarMapEntry(entry, jis));
|
||||||
|
}
|
||||||
|
} catch (IOException e) {
|
||||||
|
e.printStackTrace();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public InputStream getInputStream() {
|
||||||
|
try {
|
||||||
|
return isInputStream ? is : new FileInputStream(file);
|
||||||
|
} catch (FileNotFoundException e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public Manifest getManifest() throws IOException {
|
||||||
|
loadJarInputStream();
|
||||||
|
return isInputStream ? jis.getManifest() : jarFile.getManifest();
|
||||||
|
}
|
||||||
|
|
||||||
|
public InputStream getInputStream(ZipEntry ze) throws IOException {
|
||||||
|
loadJarInputStream();
|
||||||
|
return isInputStream ? ((JarMapEntry) bufMap.get(ze.getName())).getInputStream() :
|
||||||
|
jarFile.getInputStream(ze);
|
||||||
|
}
|
||||||
|
|
||||||
|
public OutputStream getOutputStream(ZipEntry ze) {
|
||||||
|
if (!isInputStream) // Only support inputstream mode
|
||||||
|
return null;
|
||||||
|
loadJarInputStream();
|
||||||
|
ByteArrayStream bs = ((JarMapEntry) bufMap.get(ze.getName())).data;
|
||||||
|
bs.reset();
|
||||||
|
return bs;
|
||||||
|
}
|
||||||
|
|
||||||
|
public byte[] getRawData(ZipEntry ze) throws IOException {
|
||||||
|
if (isInputStream) {
|
||||||
|
loadJarInputStream();
|
||||||
|
return ((JarMapEntry) bufMap.get(ze.getName())).data.toByteArray();
|
||||||
|
} else {
|
||||||
|
ByteArrayStream bytes = new ByteArrayStream();
|
||||||
|
bytes.readFrom(jarFile.getInputStream(ze));
|
||||||
|
return bytes.toByteArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public Enumeration<JarEntry> entries() {
|
||||||
|
loadJarInputStream();
|
||||||
|
return isInputStream ? Collections.enumeration(bufMap.values()) : jarFile.entries();
|
||||||
|
}
|
||||||
|
|
||||||
|
public ZipEntry getEntry(String name) {
|
||||||
|
return getJarEntry(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
public JarEntry getJarEntry(String name) {
|
||||||
|
loadJarInputStream();
|
||||||
|
return isInputStream ? bufMap.get(name) : jarFile.getJarEntry(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void close() throws IOException {
|
||||||
|
if (isInputStream)
|
||||||
|
is.close();
|
||||||
|
else
|
||||||
|
jarFile.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static class JarMapEntry extends JarEntry {
|
||||||
|
ByteArrayStream data;
|
||||||
|
JarMapEntry(JarEntry je, InputStream is) {
|
||||||
|
super(je);
|
||||||
|
data = new ByteArrayStream();
|
||||||
|
data.readFrom(is);
|
||||||
|
}
|
||||||
|
InputStream getInputStream() {
|
||||||
|
return data.getInputStream();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -1,7 +1,9 @@
|
|||||||
package com.topjohnwu.magisk.utils;
|
package com.topjohnwu.magisk.utils;
|
||||||
|
|
||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
import android.text.TextUtils;
|
|
||||||
|
import com.topjohnwu.magisk.container.ByteArrayStream;
|
||||||
|
import com.topjohnwu.magisk.container.JarMap;
|
||||||
|
|
||||||
import org.bouncycastle.asn1.ASN1InputStream;
|
import org.bouncycastle.asn1.ASN1InputStream;
|
||||||
import org.bouncycastle.asn1.ASN1ObjectIdentifier;
|
import org.bouncycastle.asn1.ASN1ObjectIdentifier;
|
||||||
@ -90,60 +92,43 @@ public class ZipUtils {
|
|||||||
public native static void zipAdjust(String filenameIn, String filenameOut);
|
public native static void zipAdjust(String filenameIn, String filenameOut);
|
||||||
|
|
||||||
public static String generateUnhide(Context context, File output) {
|
public static String generateUnhide(Context context, File output) {
|
||||||
File temp = new File(context.getCacheDir(), "temp.apk");
|
|
||||||
String pkg = "";
|
|
||||||
try {
|
try {
|
||||||
JarInputStream source = new JarInputStream(context.getAssets().open(UNHIDE_APK));
|
String pkg;
|
||||||
JarOutputStream dest = new JarOutputStream(new FileOutputStream(temp));
|
JarMap apk = new JarMap(context.getAssets().open(UNHIDE_APK));
|
||||||
JarEntry entry;
|
JarEntry je = new JarEntry(ANDROID_MANIFEST);
|
||||||
int size;
|
byte xml[] = apk.getRawData(je);
|
||||||
byte buffer[] = new byte[4096];
|
int offset = -1;
|
||||||
while ((entry = source.getNextJarEntry()) != null) {
|
|
||||||
dest.putNextEntry(new JarEntry(entry.getName()));
|
|
||||||
if (TextUtils.equals(entry.getName(), ANDROID_MANIFEST)) {
|
|
||||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
|
||||||
while((size = source.read(buffer)) != -1) {
|
|
||||||
baos.write(buffer, 0, size);
|
|
||||||
}
|
|
||||||
int offset = -1;
|
|
||||||
byte xml[] = baos.toByteArray();
|
|
||||||
|
|
||||||
// Linear search pattern offset
|
// Linear search pattern offset
|
||||||
for (int i = 0; i < xml.length - UNHIDE_PKG_NAME.length; ++i) {
|
for (int i = 0; i < xml.length - UNHIDE_PKG_NAME.length; ++i) {
|
||||||
boolean match = true;
|
boolean match = true;
|
||||||
for (int j = 0; j < UNHIDE_PKG_NAME.length; ++j) {
|
for (int j = 0; j < UNHIDE_PKG_NAME.length; ++j) {
|
||||||
if (xml[i + j] != UNHIDE_PKG_NAME[j]) {
|
if (xml[i + j] != UNHIDE_PKG_NAME[j]) {
|
||||||
match = false;
|
match = false;
|
||||||
break;
|
break;
|
||||||
}
|
|
||||||
}
|
|
||||||
if (match) {
|
|
||||||
offset = i;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (offset < 0)
|
|
||||||
return "";
|
|
||||||
|
|
||||||
// Patch binary XML with new package name
|
|
||||||
pkg = Utils.genPackageName("com.", UNHIDE_PKG_NAME.length - 1);
|
|
||||||
System.arraycopy(pkg.getBytes(), 0, xml, offset, pkg.length());
|
|
||||||
dest.write(xml);
|
|
||||||
} else {
|
|
||||||
while((size = source.read(buffer)) != -1) {
|
|
||||||
dest.write(buffer, 0, size);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (match) {
|
||||||
|
offset = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
source.close();
|
if (offset < 0)
|
||||||
dest.close();
|
return "";
|
||||||
signZip(context, temp, output, false);
|
|
||||||
temp.delete();
|
// Patch binary XML with new package name
|
||||||
|
pkg = Utils.genPackageName("com.", UNHIDE_PKG_NAME.length - 1);
|
||||||
|
System.arraycopy(pkg.getBytes(), 0, xml, offset, pkg.length());
|
||||||
|
apk.getOutputStream(je).write(xml);
|
||||||
|
|
||||||
|
// Sign the APK
|
||||||
|
signZip(context, apk, output, false);
|
||||||
|
|
||||||
|
return pkg;
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
e.printStackTrace();
|
e.printStackTrace();
|
||||||
return pkg;
|
return "";
|
||||||
}
|
}
|
||||||
return pkg;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void removeTopFolder(InputStream in, File output) throws IOException {
|
public static void removeTopFolder(InputStream in, File output) throws IOException {
|
||||||
@ -174,7 +159,6 @@ public class ZipUtils {
|
|||||||
dest.close();
|
dest.close();
|
||||||
in.close();
|
in.close();
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
Logger.dev("ZipUtils: removeTopFolder IO error!");
|
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -201,7 +185,6 @@ public class ZipUtils {
|
|||||||
} else {
|
} else {
|
||||||
name = entry.getName();
|
name = entry.getName();
|
||||||
}
|
}
|
||||||
Logger.dev("ZipUtils: Extracting: " + entry);
|
|
||||||
File dest = new File(folder, name);
|
File dest = new File(folder, name);
|
||||||
dest.getParentFile().mkdirs();
|
dest.getParentFile().mkdirs();
|
||||||
FileOutputStream out = new FileOutputStream(dest);
|
FileOutputStream out = new FileOutputStream(dest);
|
||||||
@ -218,9 +201,24 @@ public class ZipUtils {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static void signZip(Context context, InputStream is, File output, boolean minSign) {
|
||||||
|
try {
|
||||||
|
signZip(context, new JarMap(is, false), output, minSign);
|
||||||
|
} catch (IOException e) {
|
||||||
|
e.printStackTrace();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public static void signZip(Context context, File input, File output, boolean minSign) {
|
public static void signZip(Context context, File input, File output, boolean minSign) {
|
||||||
|
try {
|
||||||
|
signZip(context, new JarMap(new FileInputStream(input), false), output, minSign);
|
||||||
|
} catch (IOException e) {
|
||||||
|
e.printStackTrace();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void signZip(Context context, JarMap input, File output, boolean minSign) {
|
||||||
int alignment = 4;
|
int alignment = 4;
|
||||||
JarFile inputJar = null;
|
|
||||||
BufferedOutputStream outputFile = null;
|
BufferedOutputStream outputFile = null;
|
||||||
int hashes = 0;
|
int hashes = 0;
|
||||||
try {
|
try {
|
||||||
@ -235,9 +233,8 @@ public class ZipUtils {
|
|||||||
|
|
||||||
outputFile = new BufferedOutputStream(new FileOutputStream(output));
|
outputFile = new BufferedOutputStream(new FileOutputStream(output));
|
||||||
if (minSign) {
|
if (minSign) {
|
||||||
ZipUtils.signWholeFile(input, publicKey, privateKey, outputFile);
|
ZipUtils.signWholeFile(input.getInputStream(), publicKey, privateKey, outputFile);
|
||||||
} else {
|
} else {
|
||||||
inputJar = new JarFile(input, false); // Don't verify.
|
|
||||||
JarOutputStream outputJar = new JarOutputStream(outputFile);
|
JarOutputStream outputJar = new JarOutputStream(outputFile);
|
||||||
// For signing .apks, use the maximum compression to make
|
// For signing .apks, use the maximum compression to make
|
||||||
// them as small as possible (since they live forever on
|
// them as small as possible (since they live forever on
|
||||||
@ -246,16 +243,16 @@ public class ZipUtils {
|
|||||||
// and produces output that is only a tiny bit larger
|
// and produces output that is only a tiny bit larger
|
||||||
// (~0.1% on full OTA packages I tested).
|
// (~0.1% on full OTA packages I tested).
|
||||||
outputJar.setLevel(9);
|
outputJar.setLevel(9);
|
||||||
Manifest manifest = addDigestsToManifest(inputJar, hashes);
|
Manifest manifest = addDigestsToManifest(input, hashes);
|
||||||
copyFiles(manifest, inputJar, outputJar, timestamp, alignment);
|
copyFiles(manifest, input, outputJar, timestamp, alignment);
|
||||||
signFile(manifest, inputJar, publicKey, privateKey, outputJar);
|
signFile(manifest, input, publicKey, privateKey, outputJar);
|
||||||
outputJar.close();
|
outputJar.close();
|
||||||
}
|
}
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
e.printStackTrace();
|
e.printStackTrace();
|
||||||
} finally {
|
} finally {
|
||||||
try {
|
try {
|
||||||
if (inputJar != null) inputJar.close();
|
if (input != null) input.close();
|
||||||
if (outputFile != null) outputFile.close();
|
if (outputFile != null) outputFile.close();
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
e.printStackTrace();
|
e.printStackTrace();
|
||||||
@ -335,7 +332,7 @@ public class ZipUtils {
|
|||||||
* Add the hash(es) of every file to the manifest, creating it if
|
* Add the hash(es) of every file to the manifest, creating it if
|
||||||
* necessary.
|
* necessary.
|
||||||
*/
|
*/
|
||||||
private static Manifest addDigestsToManifest(JarFile jar, int hashes)
|
private static Manifest addDigestsToManifest(JarMap jar, int hashes)
|
||||||
throws IOException, GeneralSecurityException {
|
throws IOException, GeneralSecurityException {
|
||||||
Manifest input = jar.getManifest();
|
Manifest input = jar.getManifest();
|
||||||
Manifest output = new Manifest();
|
Manifest output = new Manifest();
|
||||||
@ -490,12 +487,12 @@ public class ZipUtils {
|
|||||||
* reduce variation in the output file and make incremental OTAs
|
* reduce variation in the output file and make incremental OTAs
|
||||||
* more efficient.
|
* more efficient.
|
||||||
*/
|
*/
|
||||||
private static void copyFiles(Manifest manifest, JarFile in, JarOutputStream out,
|
private static void copyFiles(Manifest manifest, JarMap in, JarOutputStream out,
|
||||||
long timestamp, int alignment) throws IOException {
|
long timestamp, int alignment) throws IOException {
|
||||||
byte[] buffer = new byte[4096];
|
byte[] buffer = new byte[4096];
|
||||||
int num;
|
int num;
|
||||||
Map<String, Attributes> entries = manifest.getEntries();
|
Map<String, Attributes> entries = manifest.getEntries();
|
||||||
ArrayList<String> names = new ArrayList<String>(entries.keySet());
|
ArrayList<String> names = new ArrayList<>(entries.keySet());
|
||||||
Collections.sort(names);
|
Collections.sort(names);
|
||||||
boolean firstEntry = true;
|
boolean firstEntry = true;
|
||||||
long offset = 0L;
|
long offset = 0L;
|
||||||
@ -564,15 +561,15 @@ public class ZipUtils {
|
|||||||
// Used for signWholeFile
|
// Used for signWholeFile
|
||||||
private static class CMSProcessableFile implements CMSTypedData {
|
private static class CMSProcessableFile implements CMSTypedData {
|
||||||
|
|
||||||
private File file;
|
private InputStream is;
|
||||||
private ASN1ObjectIdentifier type;
|
private ASN1ObjectIdentifier type;
|
||||||
private byte[] buffer;
|
ByteArrayStream bos;
|
||||||
int bufferSize = 0;
|
|
||||||
|
|
||||||
CMSProcessableFile(File file) {
|
CMSProcessableFile(InputStream is) {
|
||||||
this.file = file;
|
this.is = is;
|
||||||
type = new ASN1ObjectIdentifier(CMSObjectIdentifiers.data.getId());
|
type = new ASN1ObjectIdentifier(CMSObjectIdentifiers.data.getId());
|
||||||
buffer = new byte[4096];
|
bos = new ByteArrayStream();
|
||||||
|
bos.readFrom(is);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@ -582,30 +579,20 @@ public class ZipUtils {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void write(OutputStream out) throws IOException, CMSException {
|
public void write(OutputStream out) throws IOException, CMSException {
|
||||||
FileInputStream input = new FileInputStream(file);
|
bos.writeTo(out, 0, bos.size() - 2);
|
||||||
long len = file.length() - 2;
|
|
||||||
while ((bufferSize = input.read(buffer)) > 0) {
|
|
||||||
if (len <= bufferSize) {
|
|
||||||
out.write(buffer, 0, (int) len);
|
|
||||||
break;
|
|
||||||
} else {
|
|
||||||
out.write(buffer, 0, bufferSize);
|
|
||||||
}
|
|
||||||
len -= bufferSize;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Object getContent() {
|
public Object getContent() {
|
||||||
return file;
|
return is;
|
||||||
}
|
}
|
||||||
|
|
||||||
byte[] getTail() {
|
byte[] getTail() {
|
||||||
return Arrays.copyOfRange(buffer, 0, bufferSize);
|
return Arrays.copyOfRange(bos.getBuf(), bos.size() - 22, bos.size());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void signWholeFile(File input, X509Certificate publicKey,
|
private static void signWholeFile(InputStream input, X509Certificate publicKey,
|
||||||
PrivateKey privateKey, OutputStream outputStream)
|
PrivateKey privateKey, OutputStream outputStream)
|
||||||
throws Exception {
|
throws Exception {
|
||||||
ByteArrayOutputStream temp = new ByteArrayOutputStream();
|
ByteArrayOutputStream temp = new ByteArrayOutputStream();
|
||||||
@ -666,7 +653,7 @@ public class ZipUtils {
|
|||||||
outputStream.write((total_size >> 8) & 0xff);
|
outputStream.write((total_size >> 8) & 0xff);
|
||||||
temp.writeTo(outputStream);
|
temp.writeTo(outputStream);
|
||||||
}
|
}
|
||||||
private static void signFile(Manifest manifest, JarFile inputJar,
|
private static void signFile(Manifest manifest, JarMap inputJar,
|
||||||
X509Certificate publicKey, PrivateKey privateKey,
|
X509Certificate publicKey, PrivateKey privateKey,
|
||||||
JarOutputStream outputJar)
|
JarOutputStream outputJar)
|
||||||
throws Exception {
|
throws Exception {
|
||||||
|
Loading…
Reference in New Issue
Block a user